16. React Redux

Wyzwania:

  • zadbasz o jakość swojego kodu i nauczysz się debugować Reacta,
  • poznasz technologię Redux, bardzo często używaną razem z Reactem,
  • rozwiniesz aplikację to-do o filtrowanie kart,
  • opublikujesz swój projekt.

16.1. Unikanie i rozwiązywanie bugów

Jak na pewno pamiętasz, wielokrotnie w czasie kursu poruszaliśmy kwestię czystości i poprawności kodu. W tym celu instalowaliśmy różne narzędzia, które pomagały nam wystrzegać się błędów i złych praktyk.

Narzędzia te sprawdzały poprawność naszego kodu w momencie uruchomienia taska build lub watch: terminal zgłaszał błąd i nie pozwalał na uruchomienie podglądu projektu. To podejście, mimo swoich niewątpliwych zalet (wymuszanie dobrych nawyków, wyłapywanie pomyłek w kodzie), ma kilka istotnych wad:

  • uniemożliwia nam uruchomienie podglądu strony, do czasu naprawienia błędów,
  • jeśli nie restartowaliśmy task runnera, błędy kumulowały się i trzeba było naprawiać wszystkie naraz,
  • nie wszystkie wyniki bieżących testów muszą nas interesować w danym momencie.

Czasem możesz spotkać się z sytuacją, gdy w podczas pracy nad projektem umyślnie chcesz zapisać błędny kod, ale zależy Ci na tym, by na końcu nie trafił on do repozytorium. Odpowiedzią na ten problem jest uruchamianie sprawdzania kodu dopiero w momencie zapisu commita. Dzięki temu będziesz mieć możliwość skompilowania błędnego kodu (np. dla potrzeb testów) i niezakłóconej pracy nad projektem, z gwarancją, że do repo trafi już poprawna wersja.

Dodajmy zatem do naszego reactowego projektu kilka pomocniczych paczek, które będą pilnować, by do repozytorium nie wkradły się błędy.

ESLint

Na początku zainstalujemy dobrze Ci znaną paczkę ESLint. Otwórz terminal w katalogu projektu i wpisz:

npm install -D eslint

Flaga -D przy instalacji pakietu

Do tej pory, przy instalacji pakietów NPM używaliśmy dwóch flag: --save oraz --save-dev. Dzięki nim instalowane pakiety były zapisywane w package.json odpowiednio w sekcji dependencies lub devDependencies.

Zastanawiając się, którą z tych flag wybrać, musimy zadać sobie pytanie: czy serwer, na którym opublikujemy projekt, będzie potrzebował tego pakietu?

W naszych projektach katalog dist nie jest umieszczany w repozytorium, więc Heroku będzie musiało zbudować projekt. W takim razie będzie potrzebować wszystkich pakietów niezbędnych do uruchomienia npm run build. Dlatego w devDependencies naszego projektu znajdował się do tej pory tylko pakiet webpack-dev-server – wszystkie pozostałe są potrzebne do zbudowania projektu.

Warto o tym pamiętać, ponieważ w swojej przyszłej pracy możesz spotkać się z sytuacją, w której firmowy serwer posiada np. zainstalowanego Reacta i kilka innych pakietów – wtedy powinny one trafić do devDependencies.

Teraz kiedy instalujemy ESLinta, również dodamy go do devDependencies – służy do tego flaga --save-dev, którą można zapisać skrótowo jako -D. Flaga --save również ma krótszą formę – -S.

Następnie w folderze projektu stwórz nowy plik .eslintrc.json i wklej do niego poniższy kod:

{
  "env": {
     "es6": true,
     "browser": true,
     "node": true
  },
  "parserOptions": {
    "ecmaVersion": 6,
    "sourceType": "module",
    "ecmaFeatures": {
        "jsx": true
    }
  },
  "extends": "eslint:recommended",
  "rules": {
    "indent": [
      "error",
      2,
      {"SwitchCase": 1}
    ],
    "linebreak-style": [
      "off"
    ],
    "quotes": [
      "error",
      "single",
      {"allowTemplateLiterals": true}
    ],
    "semi": [
      "error",
      "always"
    ],
    "comma-dangle": [
      "error",
      "always-multiline"
    ],
    "no-console": "off"
  }
}

W pliku package.json natomiast dodaj do sekcji scripts nową komendę, uruchamiającą nasz linter w katalogu src:

"lint": "eslint src/",

Jeżeli spróbujesz teraz odpalić task lint w katalogu swojego projektu, terminal najprawdopodobniej zgłosi błąd. Jest to spowodowane przez fakt, że ESLint nie wie, że nasz projekt korzysta z Reacta i że w związku z tym należy użyć specjalnych reguł. Już to naprawiamy!

ESLint dla Reacta

Aby poinformować ESLint, że ma do czynienia z kodem Reacta, musimy zainstalować dodatkowy plugin – eslint-plugin-react:

npm install -D eslint-plugin-react

Na końcu musimy sprawić, by ESLint był w stanie sprawdzać kod skompilowany przez Babela. Potrzebna nam będzie do tego jeszcze jedna paczka, babel-eslint. Zainstaluj ją za pomocą komendy:

npm install -D babel-eslint

Wróćmy teraz do pliku .eslintrc.json i poinformujmy go, że powinien używać zainstalowanych przed chwilą paczek do sprawdzania kodu naszego projektu. W tym celu zmień dotychczasową zawartość pliku na poniższą:

{
  "env": {
     "es6": true,
     "browser": true,
     "node": true
  },
  "parser": "babel-eslint",
  "parserOptions": {
    "ecmaVersion": 6,
    "sourceType": "module",
    "ecmaFeatures": {
        "jsx": true
    }
  },
  "settings": {
    "react": {
      "version": "detect"
    }
  },
  "plugins": [
    "react"
  ],
  "extends": [
    "eslint:recommended",
    "plugin:react/recommended"
  ],
  "rules": {
    "indent": [
      "error",
      2,
      {"SwitchCase": 1}
    ],
    "linebreak-style": [
      "off"
    ],
    "quotes": [
      "error",
      "single",
      {"allowTemplateLiterals": true}
    ],
    "semi": [
      "error",
      "always"
    ],
    "comma-dangle": [
      "error",
      "always-multiline"
    ],
    "no-console": "off"
  }
}

Jeżeli teraz spróbujesz uruchomić komendę npm run lint w katalogu projektu, zobaczysz w terminalu, że linter działa i zapewne zgłasza już jakieś błędy w kodzie. Wbrew pozorom, to dobra wiadomość! ;)

Co ESLint uzna za błąd?

Przy każdym błędzie znalezionym przez ESLinta – o ile nie jest to błąd składni JS, uniemożliwiający jego wykonanie – w komunikacie o błędzie jest podana reguła, która została złamana.

image

W przykładzie z powyższego screena jest to zasada comma-dangle. W pliku .eslintrc.json ustawiliśmy dla tej zasady wartość "always-multiline", która oznacza, że w przypadku obiektów i tablic rozpisanych na wiele linii, musimy dodawać przecinek po ostatnim elemencie.

image

Jest to konwencja, którą przyjęliśmy w naszym kodzie, ponieważ jest często stosowana przez developerów.

Pozostałe zdefiniowane przez nas reguły dotyczą wcięć kodu w postaci dwóch spacji (indent), znaków zakończenia linii (linebreak-style), używania pojedynczych cudzysłowów (quotes), oraz konieczności używania średników na końcu linii (semi). Wyłączyliśmy też regułę zabraniającą pozostawiania console.log w kodzie, ponieważ w trakcie nauki często będą nam potrzebne.

Szybko jednak spotkasz się z sytuacją, kiedy ESLint zgłosi łamanie innych reguł, niż wymienione powyżej. Wynika to z tego, że oprócz reguł wymienionych wprost, włączyliśmy również reguły rekomendowane przez ESLinta oraz plugin eslint-plugin-react. Dzięki temu możemy być spokojni, że będziemy podążać za standardami branży. Zmieniliśmy tylko te zasady, które są kwestią konwencji i będą służyły spójności naszego kodu.

Zanim jednak zaczniemy zajmować się naprawianiem błędów, znalezionych przez ESLinta, dokończymy konfigurację naszego środowiska. Jak jednak wspomnieliśmy na początku, będziemy chcieli, aby linter uruchamiał się dopiero w momencie, gdy będziemy robić commit.

Uruchamianie lintera w momencie commita

Do osiągnięcia tego celu przyda nam się paczka husky, która odpowiada za wywoływanie zdefiniowanych przez nas akcji w odpowiedzi na określone komendy Gita (np. commit lub push). Zainstalujmy ją w znany już nam sposób:

npm install -D husky

W pliku package.json musimy teraz dodać komendę, która wywoła nasz linter w momencie commita. Moglibyśmy zrobić to, dodając nową sekcję:

"husky": {
  "hooks": {
    "pre-commit": "npm run lint"
  }
},

Ten kod odpaliłby komendę npm run lint (czyli uruchomił naszego ESLinta) w momencie, gdy próbowalibyśmy zrobić commit. Czy domyślasz się jednak, jaką wadę miałoby powyższe rozwiązanie?

Problemem jest to, że komenda npm run lint sprawdza cały folder src, co znaczy, że cały nasz kod byłby niepotrzebnie testowany za każdym commitem. Rozwiązaniem jest użycie narzędzia lint-staged, dzięki któremu będziemy sprawdzać tylko te pliki, które chcemy zmienić w danym commicie. Aby użyć lint-staged w połączeniu z husky, zainstaluj niezbędną paczkę:

npm install -D lint-staged

Następnie w pliku package.json, pod sekcją scripts, dodaj:

"husky": {
  "hooks": {
    "pre-commit": "lint-staged"
  }
},
"lint-staged": {
  "src/**/*.js": "eslint"
},

Gotowe! Teraz przy każdej próbie zapisania commita uruchomi się husky, który wywoła komendę lint-staged, a ta z kolei przetestuje za pomocą ESLinta pliki .js – ale tylko te, które zmienialiśmy w danym commicie. Jeżeli zostanie wykryty błąd, commit nie zostanie zapisany, a w terminalu dostaniesz odpowiednią wiadomość.

Tym bardziej istotne będzie czytanie komunikatów, pojawiających się w terminalu! Spróbuj teraz dodać błędny kod – np. na początku pliku index.js wpisz const test = "1" – to wyrażenie zawiera kilka błędów, więc powinno uniemożliwić nam zapisanie commita. Wykonaj komendy git add . oraz git commit -m "Add ESLint and husky" i zobacz, co pojawi się w terminalu.

image

Zwróć uwagę, że nie pojawiły się wszystkie błędy, znalezione wcześniej po wykonaniu komendy npm run lint – wtedy sprawdziliśmy wszystkie pliki projektu, a teraz tylko te, które zmieniły się od ostatniego commita!

Usuń błędny kod, który testowo wpisaliśmy przed chwilą, i ponownie spróbuj zapisać commit. Jeśli nie były zmieniane żadne pliki, poza .eslintrc.json i package.json (oraz package-lock.json), commit powinien zostać zapisany bez problemu.

image

Wyjątkowe sytuacje

Okazjonalnie może się zdarzyć, że będziesz mieć potrzebę zapisania commita zawierającego błędy. Może to być np. potrzeba zadania Mentorowi pytania o przyczynę błędu lub potrzeba błyskawicznego wysłania najnowszych zmian na zdalne repozytorium, jeśli np. pracowaliśmy nad projektem na pożyczonym komputerze.

Tylko i wyłącznie w takich ekstremalnych przypadkach możemy użyć flagi --no-verify dodanej do komendy zapisania commita. Sprawi ona, że husky nie będzie uruchamiał sprawdzenia poprawności plików zapisanych w commicie.

Pamiętaj jednak, aby nie nadużywać tej możliwości, ponieważ ESLint pozwoli Ci unikać błędów, a przez to szybciej rozwijać swoje umiejętności programistyczne. Właśnie dlatego stosowanie ESLinta i kierowanie się dobrymi praktykami jest obowiązkowe w pozostałej części kursu.

Unikanie błędów na bieżąco

Naprawianie kilku czy kilkunastu błędów nie należy do najprzyjemniejszych zajęć. Dlatego warto sprawdzić, czy dla Twojego edytora kodu istnieje plugin wyświetlający błędy wykrywane przez ESLinta.

Przykładem może być tutaj Visual Studio Code z pluginem ESLint. Dodaje on czerwone podkreślenie błędów, a po wskazaniu ich kursorem wyświetla informacje dotyczące danego błędu.

image

Takie rozwiązanie pomoże Ci unikać błędów na bieżąco i pozwoli Ci na znacznie szybsze wyrobienie sobie nawyku pisania poprawnego kodu. Gorąco zachęcamy do wykorzystania tego podejścia!

Debugowanie Reacta

Każdy developer wie, że czasem, pomimo najszczerszych chęci, coś po prostu nie działa. W takim przypadku dobrze jest mieć na podorędziu narzędzia, które pomogą zdiagnozować, gdzie leży problem. Jedno z takich narzędzi dobrze już znasz – Inspektor (dev tools) w przeglądarce pozwala Ci badać zarówno HTML/CSS, jak i JavaScript. Potrafisz też już również czytać komunikaty wyświetlane w konsoli przeglądarki oraz w terminalu. Kiedy któryś komunikat o błędzie jest dla Ciebie niezrozumiały – poradzisz sobie, wyszukując go w Google.

Umiejętność obsługi narzędzi developerskich i terminala będzie kluczowa przy debugowaniu Reacta. Jeśli jednak spotkasz się z sytuacją, w której komunikat o błędzie jest zbyt ogólny i nie pomaga znaleźć źródła błędu, możesz zapisać commit, aby porównać zmiany względem poprzedniego commita (np. za pomocą podglądu repozytorium przez stronę GitHuba). Pozwoli Ci to cofnąć część wprowadzonych zmian, aby dowiedzieć się która zmiana w plikach powoduje błąd.

W debugowaniu – a także lepszym rozumieniu – Reacta pomoże nam też narzędzie przeznaczone specjalnie do badania kodu reactowego.

React Developer Tools

Pomimo wszystkich swoich zalet, domyślne narzędzia developerskie nie pozwalają zajrzeć nam do wnętrza komponentów reactowych. Ma to sens, ponieważ pokazują elementy DOM wyświetlane na stronie – ale nam przydałoby się coś więcej.

Na szczęście z pomocą przychodzi nam bardzo użyteczna wtyczka do przeglądarki – React Developer Tools. Zainstaluj ją w swojej przeglądarce, a następnie otwórz swoją aplikację reactową i wejdź w panel narzędzi developerskich. Zobaczysz w nich nową zakładkę o nazwie "Components":

image

Poniżej, w okienku po lewej stronie zauważysz listę komponentów, a gdy klikniesz któryś z nich, w prawej części panelu pojawią się jego propsy (możesz je w tym miejscu edytować).

Dzięki temu rozszerzeniu narzędzi developerskich możesz teraz badać komponenty reactowe oraz sprawdzać ich propsy i stan. Pozwoli Ci to na lepsze zrozumienie tworzonej aplikacji oraz ułatwi debugowanie problemów np. z przekazywaniem propsów.

Zadanie: Poprawienie błędów znalezionych przez ESLinta

Na rozgrzewkę tego modułu, Twoim zadaniem będzie dokładne przeczytanie komunikatów ESLinta, które pojawiają się po wykonaniu polecenia npm run lint – i oczywiście naprawienie znalezionych błędów. ;)

Po naprawieniu wszystkich błędów zapisz nowy commit, a link do repo wklej poniżej i wyślij do Mentora.

16.2. Rozwój funkcjonalności projektu

Nasz projekt to-do zaczyna nabierać kształtów: po wykonaniu zadań z poprzedniego modułu można w nim dodawać nowe kolumny z zadaniami, a w kolumnach nowe karty. Zastanówmy się, w jaki sposób możemy go wzbogacić!

Większość list z zadaniami ma tendencję do niekontrolowanego rozrastania się wraz z biegiem czasu, przez co ciężko zapanować nad tym, co właściwie jest do zrobienia. Receptą na ten problem może być funkcjonalność filtrowania zadań, dzięki czemu łatwo będzie odnaleźć interesującą nas kartę. Dodamy do projektu pole tekstowe, w które można wpisać wyszukiwaną frazę. W rezultacie karty niepasujące do tego wyrażenia powinny zostać ukryte.

Kolejnym pomysłem, który moglibyśmy wprowadzić po filtrowaniu, jest przenoszenie kart między kolumnami, dzięki któremu łatwo będzie można posegregować zadania wedle pilności lub terminu wykonania.

Wyzwania przy wdrożeniu nowych funkcjonalności

Przed przystąpieniem do pisania kodu lub nawet obmyślania szczegółowego algorytmu, zastanówmy się na dość ogólnym poziomie, co tak naprawdę chcemy osiągnąć i w jaki sposób zmodyfikować działanie aplikacji. Takie podejście do problemu pomoże nam ułożyć sobie w głowie, do czego dążymy, jakie problemy napotkamy po drodze i jak możemy sobie z nimi poradzić.

Weźmy na początek filtrowanie zadań na naszej liście. Aktualna lista kart jest przechowywana w stanach poszczególnych kolumn, a lista kolumn – w stanie listy. Na ten moment mamy tylko jedną listę, ale nasza aplikacja ma być przygotowana pod obsługę wielu list – których lista będzie przechowywana w komponencie App.

Kiedy dodamy wyszukiwanie, App będzie musiał przekazywać wyszukiwaną frazę do List, który następnie przekaże ją do Column. Jednak przy polu wyszukiwania chcemy wyświetlić liczbę znalezionych kart – więc będziemy też musieli przekazywać funkcję, którą wykona Column, aby poinformować App o liczbie wyświetlanych kart. Następnie App musi podsumować informacje przekazane ze wszystkich kolumn i wyświetlić je przy polu wyszukiwania.

Bardzo możliwe, że chcielibyśmy też dodać zupełnie inny widok wyszukanych kart – może w przyszłości wolelibyśmy, aby wyniki wyszukiwania nie były wyświetlane w kolumnach, tylko w jednym kontenerze? Wtedy kolumny musiałyby przekazywać również listę znalezionych kart, a App musiałby scalać informacje ze wszystkich kolumn i przekazywać je do zupełnie nowego komponentu.

Robi się skomplikowanie, a to dopiero pierwsza funkcjonalność – przy przenoszeniu kart nie będzie łatwiej. Kiedy przenosimy kartę, musimy zaktualizować dwie kolumny – tę, z której usuwamy kartę, oraz tę, do której karta zostanie przeniesiona. Wszystkie dane karty będą musiały zostać usunięte z pierwszej z nich i dodane do drugiej.

Co więcej, jeśli w przyszłości chcielibyśmy zaimplementować API przechowujące dane naszej aplikacji, musielibyśmy wykonać dwie operacje w API. Wtedy istnieje ryzyko, że przy niestabilnym połączeniu z internetem możemy stracić kartę, jeśli zerwane zostanie tylko połączenie tworzące kartę w docelowej kolumnie. Aby to rozwiązać, musielibyśmy zaprogramować nasze API tak, aby za pomocą jednego połączenia usuwało kartę w jednej kolumnie i dodawało do drugiej, ale to będzie oznaczać dodatkową pracę po stronę backendu i jeszcze bardziej komplikuje rozwiązanie problemu.

Uff! To będzie wyzwanie! Ale... czy rzeczywiście?

Redux – lepsze rozwiązanie

Na tym etapie musimy powiedzieć "dość!" i zmienić architekturę naszej aplikacji, aby nie była ona przeszkodą w rozwoju naszej aplikacji. Całe szczęście, nie my pierwsi zderzamy się z tymi problemami, więc istnieje już na nie gotowe rozwiązanie. Jest to Redux – bardzo popularna biblioteka służąca do zarządzania stanem aplikacji.

W przeciwieństwie do stanu komponentu reduksowy stan aplikacji jest globalny, a więc możemy mieć do niego dostęp z każdego komponentu. Redux wprowadza jednak znacznie więcej zmian, które pozwolą nam na uporządkowanie korzystania ze stanu aplikacji.

Po pierwsze, wprowadzimy sobie określenie magazyn (store), który będzie obiektem stanu aplikacji. Oprócz samego stanu zawiera kilka metod, pozwalających na jego obsługę.

Jak działa Redux?

Zasada działania Reduksa jest w miarę prosta: komponent zgłasza chęć zmiany stanu aplikacji, magazyn decyduje, czy wprowadzić tę zmianę, a jeśli ją wprowadzi – informuje o tym wszystkie komponenty, korzystające ze zmienionych danych.

Metafora – Redux

Wyobraź sobie, że prowadzimy firmę. Miesiąc temu wprowadziliśmy do sprzedaży nowy produkt i właśnie się zorientowaliśmy, że ktoś zrobił literówkę, wprowadzając ten produkt do programu księgowego. W związku z tym od miesiąca wysyłamy klientom faktury z błędną nazwą produktu.

Informujemy o tym księgową, która będzie musiała zdecydować, czy wystawione do tej pory faktury trzeba skorygować, czy nie. Jest to jej decyzja, bo nikogo nie wprowadzi w błąd "lampa biurkawa", ale nie możemy sprzedawać "papieru ściernego" nazywając go "ściennym".

Jeśli księgowa (magazyn) postanowi, że musi skorygować faktury (stan aplikacji), to poinformuje o tym wszystkich klientów (komponenty), których fakturę skorygowaliśmy (zmieniliśmy informacje, z których korzystają).

Wejdźmy jednak nieco głębiej w to, jak będzie działać nasza aplikacja z magazynem (store).

  • Każdy komponent możemy przerobić tak, aby mógł odczytywać stan aplikacji z magazynu – dzięki temu np. kolumna będzie mogła wyświetlić karty, które są do niej przypisane. Co więcej, jeśli w przyszłości zmienią się dane, które odczytuje ten komponent, to zostanie on na nowo wyrenderowany.
  • Jeśli komponent będzie chciał zmienić stan aplikacji (np. dodać kartę), stworzy i wyśle akcję do magazynu. Akcja to po prostu komunikat, który mówi "chcę wprowadzić zmianę w stanie aplikacji, tutaj są szczegóły tej zmiany".
  • Następnie magazyn przyjmie tę akcję i spróbuje zmienić stan aplikacji – o ile zaopatrzyliśmy go w funkcję do obsługi tej konkretnej akcji, a dane przekazane w szczegółach akcji są poprawne.
  • Jeśli magazyn zmienił stan aplikacji, to wszystkie komponenty, które korzystają ze zmienionych danych, zostaną na nowo wyrenderowane. Dzięki temu np. po dodaniu nowej karty, zostanie ona wyświetlona w odpowiedniej kolumnie.

W ten sposób zatoczyliśmy koło, zaczynając i kończąc na komponencie. To trochę tak, jakbyśmy chcieli zawrócić na rondzie – musimy przejechać dookoła całego ronda. Bardzo ważne jest to, że po tym rondzie jeździmy tylko w jednym kierunku! Komponent sam nie może bezpośrednio zmienić stanu aplikacji ani wyświetlić karty, która nie została jeszcze dodana do magazynu.

Może się to pozornie wydawać ograniczeniem, ale w rzeczywistości pozwoli to na zachowanie porządku i uniknięcie poważnych problemów.

Oczywiście, to tylko bardzo ogólny opis działania Reduksa – pozwoli nam szybko zacząć używać Reduksa i w praktyce zrozumieć jego działanie. Dopiero wtedy poruszymy szczegóły każdego z opisanych powyżej etapów jego działania.

Nauka Reduksa

Redux słynie z tego, że jest zabójczy dla początkujących developerów. Wynika to po części z faktu, że Redux jest samodzielną biblioteką, tzn. nie jest częścią Reacta. Dlatego większość podręczników i tutoriali uczy osobno działania samego Reduksa, a osobno implementacji React-Redux, czyli biblioteki łączącej Reacta z Reduksem.

Co więcej, istnieje wiele różnych wzorców architektury aplikacji reactowej, wykorzystującej Reduksa, co dodatkowo komplikuje naukę. Ciężko jest poznać nową bibliotekę, kiedy każdy tutorial pokazuje inne podejście do jej wykorzystania.

Oczywiście, na naszym kursie nie będziesz mieć z tym problemu – chcemy jednak uprzedzić Cię, że stosowane przez nas podejście jest tylko jednym z wielu. Dlatego ważne jest zrozumienie zastosowania poszczególnych fragmentów kodu, bez przywiązywania się do tego, w którym pliku zostały zawarte.

Nie przejmuj się też tym, że cały kod implementujący magazyn dostaniesz od nas – pod koniec modułu omówimy sobie, jak działa ten kod, ale na początku najważniejsza będzie nauka korzystania z magazynu. Dzięki temu skupimy się najpierw na korzystaniu z Reduksa, co pozwoli nam go lepiej zrozumieć. Dopiero potem porozmawiamy o tym, w jaki sposób działa nasz magazyn stanu aplikacji.

16.3. Odczytywanie stanu aplikacji

Przygodę z Reduksem zaczniemy od – tak, tak – zainstalowania niezbędnych pakietów npm. Następnie dodamy konfigurację magazynu.

Instalacja Reduksa

Pakiety potrzebne do uruchomienia Reduksa zainstalujemy przy pomocy komendy:

npm install -S redux@4.0.1 react-redux@7.0.1

Potrzebujemy też dodać pakiet, który pozwoli nam na podglądanie magazynu za pomocą narzędzi developerskich:

npm install -D redux-devtools-extension@2.13.8

Zainstaluj też wtyczkę Redux DevTools w wersji dla Chrome lub dla Firefoksa

Od razu po zainstalowaniu wtyczki możesz przejść na stronę naszego projektu i otworzyć narzędzie developerskie. Znajdziesz w nich nową zakładkę – Redux. Na razie jednak będzie w niej tylko informacja, że nie znaleziono magazynu (store).

image

Właśnie tak miało być – nie mamy jeszcze zaimplementowanego magazynu, więc nie było czego znaleźć. Za chwilę to zmienimy.

Konfiguracja magazynu

Jak wspomnieliśmy, zależy nam na rozpoczęciu nauki od korzystania z Reduksa, więc całą konfigurację magazynu przygotowaliśmy dla Ciebie. Zacznij od pobrania paczki z plikami.

Pobierz pliki magazynu

Następnie utwórz w projekcie katalog redux i rozpakuj plik store.js z pobranej paczki ZIP.

Plik store.js importuje obiekt initialStoreData z src/data/dataStore.js – ale na razie nie mamy takiego obiektu. Właśnie dlatego w paczce ZIP zamieściliśmy nową wersję tego pliku. Możesz go podmienić w całości albo przenieść do niego tylko nową zawartość, rozpoczynającą się od const lists.

Po tej zmianie plik dataStore.js domyślnie eksportuje obiekt initialStoreData, który zawiera treści aplikacji (tytuł i subtytuł) oraz tablice zawierające listy (na razie mamy tylko jedną), kolumny oraz karty. Zwróć uwagę, że zmieniliśmy strukturę danych – wcześniej w obiekcie kolumny zawieraliśmy tablicę z jej kartami. Teraz mamy osobną tablicę zawierającą wszystkie karty, które są powiązane z kolumnami za pomocą columnId. Analogicznie, kolumny powiązane są z listą za pomocą listId.

W stanie powinny znajdować się wyłącznie dane, które mogą ulegać zmianie w trakcie działania aplikacji. W initialStoreData zawarliśmy treści aplikacji (właściwość app), ponieważ zakładamy, że w przyszłości możemy chcieć zmieniać tytuł i/lub podtytuł, np. w zależności od wyświetlanej listy, lub przy wyświetlaniu listy wyszukanych kart.

Teraz czas na poinformowanie naszej aplikacji, że ma korzystać z magazynu Reduksa. Otwórz plik index.js i dodaj do niego nowe importy:

import { Provider } from 'react-redux';
import store from './redux/store';

Wreszcie, aby nasza aplikacja mogła korzystać z magazynu, musimy wrappować całą aplikację w komponent Provider, który przed chwilą zaimportowaliśmy. Zmień linię, w której użyliśmy komponentu App, na poniższą:

ReactDOM.render(<Provider store={store}><App /></Provider>, document.getElementById('app'));

Podgląd stanu aplikacji

Nasza aplikacja już ma podłączony magazyn, ale jeszcze go nie wykorzystujemy. Możemy jednak już teraz podejrzeć stan aplikacji za pomocą zakładki Redux w narzędziach developerskich. Upewnij się, że masz aktywne guziki/zakładki State oraz Tree, zaznaczone na poniższym screenie.

image

W górnej części widzisz listę akcji – na razie jest tylko jedna akcja, wynikająca z uruchomienia magazynu. W następnym submodule umożliwi nam to obserwowanie zmian w magazynie wywołanych kolejnymi akcjami (np. dodanie nowej karty). Na razie bardziej interesuje nas dolna część panelu, w której możesz sprawdzić zawartość stanu aplikacji.

Już widzimy, że udało nam się uruchomić magazyn z początkowym stanem, zdefiniowanym w dataStore.js. Teraz czas zacząć wykorzystywać magazyn do wyświetlania treści na stronie!

Wykorzystanie stanu w App

Będziemy implementować wykorzystanie magazynu krok po kroku. Dlatego zaczniemy od wyłączenia części aplikacji, aby skupić się tylko na komponencie App. Otwórz plik App.js, za chwilę zakomentujemy w nim wykorzystanie komponentu List.

Wyłączenie części aplikacji

Nie rozmawialiśmy jeszcze o komentarzach w kodzie JSX – działają one tak samo, jak w kodzie JS, z tym że musimy przełączyć się z trybu JSX na tryb JS, za pomocą nawiasów klamrowych { }. Będzie to wyglądało tak:

{/*
<List {...listData} />
*/}

Zakomentuj również import komponentu List u góry pliku – to nie jest kod JSX, więc użyj po prostu podwójnego slasha //.

Wykorzystanie propsów w komponencie

Po tej zmianie, na stronie powinny wyświetlać się tylko nagłówki. Ich treść jest jednak nadal pobierana z obiektu pageContents zdefiniowanego w pliku dataStore.js. Zamiast tego chcemy, aby treść była pobierana z propsów komponentu. W związku z tym zmieniamy kod JSX na:

render() {
  const {title, subtitle} = this.props;
  return (
    <main className={styles.component}>
      <h1 className={styles.title}>{title}</h1>
      <h2 className={styles.subtitle}>{subtitle}</h2>
      {/*
      <List {...listData} />
      */}
    </main>
  );
}

Możemy teraz usunąć import z pliku dataStore.js oraz dodać propTypes komponentu App:

static propTypes = {
  title: PropTypes.node,
  subtitle: PropTypes.node,
}

Po tych zmianach na stronie nie wyświetla się nic! Jeśli jednak zbadasz stronę za pomocą narzędzi developerskich (zakładka Components), zobaczysz pusty komponent App. Wynika to z tego, że komponent App nie otrzymuje żadnych propsów!

image

Powiązanie stanu aplikacji z propsami

Wszystko idzie zgodnie z planem – kolejny krok to przekazanie danych ze stanu aplikacji jako propsów komponentu App. Pewnie domyślasz się, że zrobimy to w pliku index.js, ale tu Cię zaskoczymy – powiązanie stanu aplikacji z propsami wykonamy w kontenerze!

Kontener jest nowością, którą wprowadzamy razem z Reduksem. Pozwoli nam to na oddzielenie warstwy współpracującej ze stanem od samego komponentu. Stwórz plik src/components/App/AppContainer.js i umieść w nim poniższy kod:

import {connect} from 'react-redux';
import App from './App';

const mapStateToProps = state => ({
  title: state.app.title,
});

export default connect(mapStateToProps)(App);

Teraz otwórz plik index.js i zmień import komponentu App – nie zmieniamy nazwy komponentu, tylko ścieżkę do pliku:

import App from './components/App/AppContainer';

Teraz zobacz na podgląd – pojawił się tytuł strony! Na razie tylko główny tytuł, ale jest on już pobierany ze stanu aplikacji!

Uzyskaliśmy to dzięki plikowi AppContainer.js. Wszystko, co musisz o nim wiedzieć, to:

  • importuje komponent, dla którego jest kontenerem – w tym wypadku App,
  • w stałej mapStateToProps zapisujemy funkcję, która definiuje powiązanie propsów ze stanem,
  • wyrażenie, które eksportujemy na końcu pliku, jest odpowiedzialne za połączenie komponentu App z magazynem, czyli sprawia, że wszystko działa – nie potrzebujesz rozumieć tego wyrażenia, wystarczy Ci informacja, że w ostatniej parze nawiasów musimy podać komponent, który jest wykorzystywany w tym kontenerze.

Dla zainteresowanych

Funkcja connect, zaimportowana z react-redux, zwraca funkcję, która łączy komponent ze stanem, zgodnie z przekazanymi argumentami, czyli mapStateToProps. Tę zwróconą funkcję od razu wykonujemy z argumentem App. Wynik tego wykonania funkcji zwracanej przez wykonanie funkcji jest eksportowany i może być wykorzystany w innych komponentach tak samo, jakby był to komponent App.

Jak w takim razie udostępnić treść podtytułu komponentowi App? Wystarczy, że skopiujesz linię kodu, znajdującą się w funkcji strzałkowej zapisanej w stałej mapStateToProps, czyli:

title: state.app.title,

Wklej ją drugi raz w tym samym obiekcie i zamień oba wystąpienia title na subtitle.

Pamiętaj – mapStateToProps zwraca obiekt, w którym:

  • klucz właściwości to nazwa propsa, który będzie dostępny w komponencie,
  • wartość właściwości wykorzystuje argument state do pobrania odpowiedniej informacji ze stanu aplikacji.

Po tej zmianie na stronie będą widoczne oba nagłówki aplikacji, a dodatkowo w zakładce Components powinny pojawić się propsy dla naszego komponentu App.

image

Iterowanie po tablicy list w App

Dokładnie w ten sam sposób możemy udostępnić komponentowi tablicę, znajdującą się w stanie aplikacji. Wystarczy, że w mapStateToProps dodamy:

lists: state.lists,

Wyłączenie kolumn w List i usunięcie zbędnego kodu

Zanim jednak wyświetlimy listę na stronie, musimy upewnić się, że nie korzysta ona z dotychczasowych danych. Otwórz plik List.js i zakomentuj w nim importy komponentów Column i Creator. Następnie zamknij w komentarzach divy zawierające Column oraz Creator.

{/*
<div className={styles.columns}>
  {this.state.columns.map(({key, ...columnProps}) => (
    <Column key={key} {...columnProps} />
  ))}
</div>
<div className={styles.creator}>
  <Creator text={settings.columnCreatorText} action={title => this.addColumn(title)}/>
</div>
*/}

W List nie będą nam już potrzebne stan (state) ani metoda addColumn. Możesz je usunąć.

Destrukturyzacja propsów

Następnie, na początku metody render dodaj deklarację propsów, których będziemy używać:

const {title, image, description} = this.props;

Wreszcie, zmień wykorzystanie właściwości z this.props na powyżej zdefiniowane stałe, czyli:

<Hero titleText={title} image={image} />
<div className={styles.description}>
  {ReactHtmlParser(description)}
</div>

Czym jest destrukturyzacja?

Ten zabieg zdefiniowania poszczególnych propsów jako stałe, który wykonaliśmy powyżej, jest dobrą praktyką. Pozwala on na jasne zadeklarowanie propsów, których używamy w renderowanym kodzie. Dzięki temu zwiększa się czytelność kodu JSX, a sama deklaracja stałych stanowi swego rodzaju dokumentację kodu.

Będziemy stosować destrukturyzację propsów w każdym przerabianym przez nas komponencie.

Iteracja po tablicy list w App

Możemy teraz z powrotem użyć komponentu List w App.js. Otwórz ten plik i:

  • odkomentuj import komponentu List,
  • do propTypes dodaj props lists z wartością PropTypes.array,
  • dodaj lists do pierwszej linii w metodzie render (destrukturyzacja propsów),
  • zamiast zakomentowanego kodu JSX zawierającego komponent List, wstaw poniższy kod:
{lists.map(listData => (
  <List key={listData.id} {...listData} />
))}

Jeśli wszystko poszło dobrze, na stronie powinien pojawić się nagłówek i opis listy, a w zakładce Components zobaczymy listę wraz z propsami.

image

Nieźle! Coraz większa część aplikacji korzysta ze reduksowego stanu! Jeszcze chwila, a zmigrujemy całą aplikację na Reduksa!

Zadanie: Refaktoring list i kolumn

Dokończenie migracji jest Twoim zadaniem, ale nie przejmuj się – poniżej znajdziesz wszystkie niezbędne informacje.

Wszystko, co wykonaliśmy dla komponentów App i List, teraz będziemy musieli wykonać dla komponentów List i Column. Z tą różnicą, że App musiał pobierać też swoje właściwości (title i subtitle), a List nie musi – otrzymał te informacje z komponentu App za pomocą spread operatora {...columnData}.

Następnie wprowadzisz analogiczne zmiany dla komponentów Column i Card. W rezultacie, na stronie będzie wyświetlać się lista, ze wszystkimi kolumnami i kartami.

Wyświetlenie kolumn

Zaczynamy od wyświetlenia kolumn – na razie jeszcze bez kart. Wykonaj dokładnie kroki opisane poniżej.

Krok 1: przygotowanie komponentu Column

W pliku Column.js:

  • zakomentuj importy komponentów Card i Creator,
  • usuń stan i metodę addCard,
  • w metodzie render dodaj destrukturyzację propsów (title i icon),
  • zakomentuj całą zawartość <section> poza <h3> – czyli w komentarzu znajdzie się kod iterujący po this.props.cards oraz komponent Creator.
Krok 2: stworzenie kontenera ListContainer

Stwórz plik ListContainer.js na podstawie pliku AppContainer.js, zmieniając w nim (i w jego nazwie) App na List, a lists na columns. Usuń też mapowanie propsów title i subtitle. W rezultacie plik ListContainer.js powinien wyglądać tak:

import {connect} from 'react-redux';
import List from './List';

const mapStateToProps = state => ({
  columns: state.columns,
});

export default connect(mapStateToProps)(List);
Krok 3: zmiana importu komponentu List

W pliku App.js zmień import komponentu List, aby importował z pliku ListContainer.js.

Krok 4: renderowanie kolumn w List

W pliku List.js:

  • odkomentuj import komponentu Column,
  • w pierwszej linii metody render (destrukturyzacja propsów) dodaj columns,
  • w kodzie JSX odkomentuj div zawierający komponent Column (pozostaw zakomentowany Creator),
  • wewnątrz tego diva, zmień kod iterujący po kolumnach na następujący:
{columns.map(columnData => (
  <Column key={columnData.id} {...columnData} />
))}

Jest to identyczny kod, jak ten, którego użyliśmy w App.js do iterowania po listach. Zmieniliśmy w nim tylko wystąpienia lists, listData i List na columns, columnData i Column.

Krok 5: dodanie filtrowania kolumn

Pewnie przykuło Twoją uwagę, że wyświetliły nam się cztery kolumny. Jeśli spojrzysz do pliku dataStore.js, zobaczysz, że ta kolumna jest przypisana do listy o id równym list-1. Nie mamy takiej listy, ale najwidoczniej mamy kolumnę przypisaną do niej.

Dodaliśmy tę kolumnę specjalnie, aby już teraz pokazać problem, który stałby się oczywisty przy implementacji wyświetlania kart. W tej chwili nasza lista wyświetla wszystkie kolumny, niezależnie od tego, czy są do niej przypisane, czy nie. Analogicznie, w dalszej części zadania, w każdej kolumnie znajdowałyby się wszystkie karty.

Spójrz na plik ListContainer.js – no tak, do propsa columns przypisujemy wszystkie kolumny ze stanu aplikacji! Musimy je jakoś przefiltrować, czyli wybrać tylko kolumny przypisane do tej listy. Wykorzystamy do tego drugi argument, dostępny w funkcji mapStateToPropsprops. Zawiera on właściwości listy, w tym jej id. Stworzymy w takim razie funkcję getColumnsForList, która będzie przyjmowała argumenty: state oraz props.id.

Dodaj tę funkcję przed mapStateToProps:

export const getColumnsForList = ({columns}, listId) => columns.filter(column => column.listId == listId);

Przeanalizuj tę funkcję, aby zrozumieć, w jaki sposób zwraca tablicę, zawierającą wyłącznie kolumny o listId pasującym do wyświetlanej listy. Zwróć też uwagę, że pierwszym argumentem jest state, z którego destrukturyzujemy właściwość columns.

Aby ta funkcja zadziałała, musimy jej użyć w mapStateToProps – a dodatkowo, zadeklarować w niej drugi argument props:

const mapStateToProps = (state, props) => ({
  columns: getColumnsForList(state, props.id),
});

Jeśli wszystko poszło dobrze, wyświetlają się już tylko trzy kolumny. Świetna robota! Połowa zadania za Tobą!

Wyświetlenie kart w Column

Ten etap zadania będzie już dużo prostszy! Tym razem nie będzie już żadnych modyfikacji, wszystkie zmiany będą oparte bezpośrednio o to, co zrobiliśmy do tej pory. W związku z tym opis kroków również będzie skrócony. ;)

  1. Przygotowanie komponentu Card:
    • jedyne, co potrzebujemy zrobić, to dodać destrukturyzację propsa title,
  2. Stworzenie kontenera ColumnContainer:
    • wzorujemy się na pliku ListContainer.js, zmieniając column na card, a następnie list na column (dla wszystkich wystąpień, z uwzględnieniem wielkości liter),
    • od razu stosujemy selektor (czyli funkcję getCardsForColumn, stworzoną i użytą analogicznie do getColumnsForList),
  3. Zmiana importu komponentu Column:
    • w pliku List.js zmieniamy ścieżkę w imporcie komponentu Column, aby wskazywała na plik ColumnContainer.js,
  4. Renderowanie kart w Column:
    • przywracamy import komponentu Card (Creator pozostaje zakomentowany),
    • dodajemy props cards do destrukturyzacji propsów w pierwszej linii metody render,
    • w kodzie JSX iterujemy po cards, analogicznie jak w pliku List.js iterowaliśmy po columns,
  5. Dodanie selektora kart:
    • zrobiliśmy już w pkt. 2, więc mamy z głowy. ;)

Po wykonaniu tych kroków karty powinny wyświetlić się w kolumnach. Każda karta powinna być widoczna tylko raz – czyli tylko w jednej kolumnie. Jeśli widzisz ten sam zestaw kolumn w każdej kolumnie, sprawdź implementację selektora (krok 5 w poprzedniej części zadania).

Gratulacje! Udało Ci się zmigrować wyświetlanie danych na Reduksa! Nie było tak strasznie, prawda?

Podsumowanie zadania

W następnym module zajmiemy się implementacją funkcjonalności dodawania kolumn i kart, a wtedy już cała aplikacja będzie przeniesiona na Reduksa!

Te zmiany mogą wydawać Ci się nieco żmudne – i masz rację, właśnie takie są. Nie są ekscytujące, bo odtwarzamy to, co już wcześniej działało. Potrzebowaliśmy jednak skupić się na samym Reduksie i dlatego przedmiot zadania nie mógł przykuwać zbyt dużej uwagi. Dzięki temu lepiej zrozumiesz działanie samego Reduksa i rolę kontenerów w strukturze aplikacji.

Pamiętaj jednak, że te zmiany już niedługo pozwolą nam na dodanie nowych funkcjonalności do projektu! Wtedy poczujesz, jak bardzo Redux potrafi uprościć rozwój nowych funkcjonalności aplikacji. A tymczasem, czas na zasłużony odpoczynek!

16.4. Zmiana stanu za pomocą akcji

Skoro nasza aplikacja wyświetla już wszystkie dane, pozostało nam dodanie dwóch ostatnich dotychczasowych funkcjonalności – tworzenia kolumn i kart. Dzięki temu nauczysz się, jak za pomocą akcji możemy wpływać na stan naszej aplikacji.

Dodawanie kolumn

Zaczniemy od wykonania kilku kroków, dzięki którym będzie możliwe dodawanie kolumn do listy. Następnie przeanalizujemy ten kod, aby zrozumieć, jak działają akcje i reducery (później wyjaśnimy, czym one są).

Stwórz plik src/redux/columnsRedux.js. Ten plik będzie odpowiedzialny za wszystkie szczegóły obsługi kolumn przez reduksowy magazyn. Dodaj do tego pliku następujący kod:

import shortid from 'shortid';

// selectors
export const getColumnsForList = ({columns}, listId) => columns.filter(column => column.listId == listId);

// action name creator
const reducerName = 'columns';
const createActionName = name => `app/${reducerName}/${name}`;

// action types
export const ADD_COLUMN = createActionName('ADD_COLUMN');

// action creators
export const createActionAddColumn = payload => ({ payload: { ...payload, id: shortid.generate() }, type: ADD_COLUMN });

// reducer
export default function reducer(statePart = [], action = {}) {
  switch (action.type) {
    case ADD_COLUMN:
      return [...statePart, action.payload];
    default:
      return statePart;
  }
}

Wykorzystujemy w tym pliku pakiet shortid, którego zastosowanie wyjaśnimy nieco później. Musimy go jednak już teraz zainstalować, wykonując w terminalu komendę

npm install -S shortid

Teraz w pliku src/redux/store.js musimy zaimportować powyższą funkcję reducer (import columnsReducer from './columnsRedux';), a następnie dodać ją do obiektu reducers pod kluczem columns.

const reducers = {
  columns: columnsReducer,
};

Następnie w pliku ListContainer.js usuń funkcję getColumnsForList i zaimportuj getColumnsForList oraz createActionAddColumn z pliku src/redux/columnsRedux.js. Pod koniec tego samego pliku, przed eksportem, dodaj następujący kod:

const mapDispatchToProps = (dispatch, props) => ({
  addColumn: title => dispatch(createActionAddColumn({
    listId: props.id,
    title,
  })),
});

Musimy jeszcze wykorzystać tę funkcję – dodać ją jako drugi argument funkcji connect w eksporcie:

export default connect(mapStateToProps, mapDispatchToProps)(List);

Teraz w pliku Column.js dodaj definicję domyślnej wartości propsa icon:

static defaultProps = {
  icon: settings.defaultColumnIcon,
}

Wreszcie, ostatnie zmiany wprowadzimy w pliku List.js. Dodaj propsa addColumn do propTypes z wartością PropTypes.func, a następnie dodaj go do destrukturyzacji propsów w pierwszej linii metody render.

W kodzie JSX możesz teraz odkomentować diva zawierającego Creator – musimy w nim tylko zmienić wartość propsa action na action={addColumn}. Nie zapomnij też przywrócić importu komponentu Creator.

Spójrz na stronę w przeglądarce – jeśli wszystko poszło dobrze, dodawanie kolumn powinno już działać! Przeanalizujmy teraz, co wydarzyło się w dodanym przed chwilą kodzie.

Mapowanie dispatcherów do propsów

Zacznijmy od pliku ListContainer.js. Już wcześniej mieliśmy w nim funkcję mapStateToProps, która dodaje propsy komponentu List, wykorzystując fragmenty stanu aplikacji z reduksowego magazynu (store).

Dodaliśmy funkcję mapDispatchToProps, która również dodaje propsy komponentu – jednak ich wartościami nie są dane ze stanu, ale funkcje wysyłające akcje do magazynu. Po angielsku wysłanie można przetłumaczyć jako dispatch, i właśnie stąd bierzemy nazwę funkcji, której zadaniem jest wysyłanie akcji – dispatcher.

W tym wypadku zdecydowaliśmy, że props addColumn będzie zawierał funkcję, przyjmującą jeden argument – title. Na podstawie tego argumentu zostanie wykonana funkcja dispatch (która jest argumentem funkcji mapDispatchToProps), wysyłająca akcję do magazynu.

Do stworzenia akcji używamy innej funkcji, którą stworzyliśmy sobie w columnsRedux.jscreateActionAddColumn. Zaraz do niej przejdziemy, ale najpierw skupmy się na jej argumencie. Przekazujemy w nim wszystkie informacje, które są niezbędne w nowej karcie. W tym wypadku będzie to title (argument dispatchera) oraz id listy, do której ma być dodana nowa kolumna.

Skrócony zapis właściwości obiektu

Niespodzianką dla Ciebie może być title użyte w ten sposób:

addColumn: title => dispatch(createActionAddColumn({
  listId: props.id,
  title,
})),

Jest to skrótowy sposób zapisu właściwości title, której wartością ma być wartość zmiennej o tej samej nazwie. Innymi słowy, ten kod zadziała dokładnie tak samo, jak:

addColumn: title => dispatch(createActionAddColumn({
  listId: props.id,
  title: title,
})),

Podsumowując, mapDispatchToProps to funkcja, która nadaje komponentowi propsy, w których znajdą się funkcje wysyłające akcje do magazynu. Pamiętaj, że akcja jest zgłoszeniem chęci zmiany stanu aplikacji. Za chwilę zajmiemy się kodem, który będzie przyjmował to zgłoszenie (akcję) i decydował, czy i jak wprowadzić zmiany w state.

Rola pliku columnsRedux.js

Poświęcimy teraz chwilę czasu na omówienie struktury pliku columnsRedux.js. Będziemy korzystać z tej struktury pliku przy każdym komponencie posiadającym jakiekolwiek akcje, więc dobrze się z nim zapoznać. Zaczynamy od importów, które będą takie same w każdym komponencie.

Przeczytaj następne rozdziały, zaglądając do kodu w pliku columnsRedux.js. Nie musisz uczyć się niczego na pamięć – wystarczy, że zrozumiesz, do czego służą poszczególne fragmenty kodu. Ułatwi Ci to tworzenie analogicznego pliku dla kart.

Selektory

Do tej pory używaliśmy określenia "selektor" w CSS i SCSS – oznaczało ciąg znaków, który wybierał elementy, dla których będzie zastosowany dany zestaw styli. Podobnie w Reduksie, selektor służy do wyboru elementów – w tym wypadku jednak jest to funkcja filtrująca jakiś fragment stanu aplikacji.

W tym przypadku jest to funkcja getColumnsForList, która wybiera kolumny z danej listy. Wykorzystujemy ją w ListContainer.js do przefiltrowania kolumn zawierających odpowiedni parametr listId.

Zwróć uwagę, że selektor powinien w pierwszym argumencie zawsze przyjmować state, czyli cały stan aplikacji. Dzięki temu w pliku ListContainer.js nie musimy w ogóle znać struktury stanu aplikacji – nie obchodzi nas to, czy kolumny znajdują się w state.columns, czy state.toDoApp.columns, czy w jeszcze innej właściwości stanu aplikacji.

Jest to ważne, ponieważ gdybyśmy w przyszłości potrzebowali zmienić strukturę stanu aplikacji, chcemy wprowadzać zmiany wyłącznie w plikach znajdujących się w src/redux. Kontenery komponentów mają pozostać w błogiej nieświadomości. ;)

Akcje

Kreator nazw akcji

Następnie mamy stałą reducerName, której używamy w funkcji createActionName.

W stałej reducerName zapisujemy nazwę właściwości stanu, na której będziemy wykonywać akcje. Spójrz ponownie na początkowy stan aplikacji, wyświetlany w zakładce Redux w narzędziach developerskich:

image

Widzisz, że nasz stan jest podzielony na cztery właściwości: app, lists, columns, oraz cards. Wartością reducerName ma być jedna z nazw tych właściwości. W tym przypadku będziemy operować na kolumnach, więc ustawiliśmy wartość columns.

Zwróć uwagę, że columns znajduje się również w nazwie tego pliku. To nie jest przypadek. ;)

Przejdźmy teraz do funkcji createActionName. Nie będziemy jej zmieniać – będzie nam potrzebna w każdym pliku z reduksowymi akcjami i reducerem. Służy ona do zamiany nazwy akcji na dłuższy identyfikator, składający się z trzech członów. Na przykład, jeśli wykorzystamy tę funkcję z argumentem 'TEST', w rezultacie otrzymamy ciąg znaków 'app/columns/TEST'.

Jak działają literały szablonowe

W tym miejscu pierwszy raz używamy literału szablonowego, czyli tekstu, który zawiera zmienne. Normalnie zapisalibyśmy:

'app/' + reducerName + '/' + name

Możemy jednak zapisać to samo w wyrażenie w krótszej, wygodniejszej formie. W tym celu, zamiast cudzysłowów musimy użyć backticka `. Najczęściej znajduje się on na tym samym klawiszu klawiatury, co tylda ~ – tyle że do napisania tyldy musimy wcisnąć też Shift, a do backticka nie.

W tekście zawartym w backtickach możemy używać zmiennych (lub innych wyrażeń JS), zamykając je w nawiasach klamrowych poprzedzonych znakiem dolara ${ }.

`app/${reducerName}/${name}`

W magazynie będziemy posługiwać się pełnym identyfikatorem, dzięki czemu moglibyśmy używać tych samych nazw akcji (np. ADD) dla różnych sekcji stanu aplikacji – np. dla kart oraz dla kolumn. Dla zwiększenia czytelności, na razie będziemy jednak tego unikać.

Typy akcji

W tej sekcji pliku definiujemy typy akcji, które będziemy wykorzystywać w operacjach na kolumnach. Przyjęło się, że nazwy akcji zapisuje się wielkimi literami, rozdzielając słowa podkreśleniami _. Nie jest to obowiązek, ale dobrze przyzwyczaić się do tego zapisu.

W naszym przypadku potrzebujemy tylko jednego typu akcji – dodawania kolumny. Definiujemy jej nazwę, wykorzystując funkcję createActionName. Dzięki niej nazwa akcji zapisana w stałej ADD_COLUMN przyjmie wartość 'app/columns/ADD_COLUMN'.

Kreatory akcji

Skoro mamy już zdefiniowaną nazwę akcji, potrzebujemy funkcji, która stworzy akcję. Czym jest akcja? Jest to obiekt, który zawiera dwa parametry – type określający typ akcji (jeden ze zdefiniowanych w poprzedniej sekcji), oraz payload, w którym znajdą się wszystkie dane niezbędne do wykonania tej akcji.

W przypadku dodawania nowej kolumny, w payload znajdą się wszystkie parametry potrzebne do stworzenia nowej kolumny. Zajrzyj do pliku ListContainer.js, aby zobaczyć, jak wykorzystujemy kreator akcji nazwany przez nas createActionAddColumn. Jako argumentu użyliśmy obiektu, który zawiera listId oraz title – właśnie ten obiekt zostanie wykorzystany przez createActionAddColumn jako payload.

Podsumowanie akcji

No dobrze, mamy w takim kreator nazw akcji, gdzie ustawiamy stałą reducerName. Za pomocą tego kreatora tworzymy nazwy typów akcji, np. ADD_COLUMN. Wreszcie, mamy kreator samej akcji, createActionAddColumn, który stworzy obiekt zawierający typ akcji oraz payload.

Funkcję createActionAddColumn wykorzystujemy w ListContainer, do stworzenia dispatchera akcji, który będzie zapisany w propsie addColumn.

Ten props, addColumn, wykorzystujemy w List.js, przekazując go do propsa action komponentu Creator.

W rezultacie Creator, po wykryciu kliknięcia w guzik OK, wykona funkcję z propsa action, która jest dispatcherem akcji ADD_COLUMN. A gdzie trafi ta wysłana akcja? Do magazynu, który obsłuży ją za pomocą reducera!

Reducer

Wreszcie dotarliśmy do serca Reduksa – reducera! Po polsku powinniśmy właściwie nazywać go reduktorem, ponieważ redukuje dwa argumenty do jednego, zwracanego obiektu. Konkretniej rzecz biorąc, przyjmuje aktualny stan aplikacji oraz akcję – a zwraca nowy stan aplikacji, z uwzględnieniem akcji (lub bez jej uwzględnienia).

Mówiąc wprost, reducer jest funkcją, która reaguje na dispatchowaną akcję. Innymi słowy, kiedy Creator wywoła funkcję, otrzymaną w propsie action, spowoduje to wysłanie akcji do magazynu, który uruchomi reducer – a właściwie, wszystkie reducery.

Musisz wiedzieć, że będziemy mieli kilka reducerów – co najmniej: jeden dla kolumn, a drugi dla kart. Każdy z nich zostanie uruchomiony w momencie odebrania przez magazyn jakiejś akcji (czyli chęci zmiany stanu aplikacji).

Zależy nam na tym, aby zareagował tylko jeden z reducerów – dlatego właśnie użyliśmy kreatora nazw akcji. Będziemy mieć tylko jeden reducer zajmujący się kolumnami, a wszystkie akcje dotyczące kolumn będą miały nazwę z prefiksem 'app/columns/', więc nie musimy się obawiać, że dwa reducery zareagują na tę samą akcję.

Zwróć uwagę, że w naszej architekturze aplikacji, reducer nie otrzymuje całego stanu aplikacji, tylko wycinek, którego dotyczy (w tym przypadku columns), dlatego pierwszy argument nazwaliśmy statePart,

Zanim przejdziemy do wyjaśnienia, jak reducer poradzi sobie z akcją ADD_COLUMN, musimy poznać warunki, które musi spełniać każdy reducer:

  1. Reducer zawsze zwraca stan.
  2. Reducer musi być funkcją czystą.
  3. Reducer nigdy nie zmienia otrzymanego stanu.

Reducer zawsze zwraca stan

Spójrz na kod reducera w pliku columnsRedux.js. Używamy w nim wyrażenia switch, które działa podobnie do if..else if...else. Sprawdzamy w nim typ akcji – jeśli nie będzie pasował do żadnego z wyrażeń po case:, to wykona się blok kodu rozpoczynający się od default, czyli zwrócony zostanie argument statePart.

Co więcej, użyliśmy domyślnych wartości argumentów statePart oraz action, ponieważ w czasie inicjalizacji magazynu może wystąpić sytuacja, w której reducer zostanie uruchomiony bez argumentów.

Dzięki domyślnym wartościom argumentów oraz domyślnemu zwróceniu argumentu statePart, spełniliśmy pierwszą zasadę.

Reducer musi być funkcją czystą

Funkcja czysta to taka funkcja, która zawsze da ten sam rezultat, jeśli dostanie te same argumenty. Zobaczmy przykład funkcji czystej:

const addNumbers = (a, b) => a + b;

console.log( addNumbers(2, 3) ); // 5
console.log( addNumbers(2, 3) ); // 5
console.log( addNumbers(2, 3) ); // 5

Jak widzisz, wykonaliśmy tę funkcję kilka razy z tymi samymi argumentami, i za każdym razem otrzymaliśmy ten sam wynik. Nie ma niczego, co moglibyśmy wpisać pomiędzy uruchomieniami tej funkcji, aby zmienić jej wynik.

Inaczej będzie w przypadku funkcji nieczystej – weźmy za przykład funkcję przeliczającą cenę netto na brutto:

let profitMargin = 1.1;
let tax = 1.23;

const netToGross = (a) => a * profitMargin * tax;

console.log( netToGross(10) ); // 13.53
profitMargin = 1.2;
console.log( netToGross(10) ); // 14.76
tax = 1.08;
console.log( netToGross(10) ); // 12.96

Tym razem, pomiędzy wywołaniami funkcji netToGross mogliśmy zmieniać wartości zmiennych, które wpływają na jej wynik. W związku z tym wynik był różny, mimo że za każdym razem użyliśmy tego samego argumentu.

Jak możemy zmienić tę funkcję, aby była funkcją czystą? Jej wynik musi zależeć wyłącznie od argumentów, czyli moglibyśmy napisać np.

const profitMargin = 1.1;
const tax = 1.23;

const netToGross = (a, factor1 = profitMargin, factor2 = tax) => a * factor1 * factor2;

console.log( netToGross(10) ); // 13.53
console.log( netToGross(10, 1.2) ); // 14.76
console.log( netToGross(10, 1.2, 1.08) ); // 12.96
console.log( netToGross(10, 1.2, 1.08) ); // 12.96
console.log( netToGross(10, 1.2, 1.08) ); // 12.96

Tym razem wynik zmienia się tylko, kiedy użyjemy innych argumentów. Mogliśmy użyć domyślnych wartości argumentów funkcji, ale najważniejsze, że nie mogą one ulec zmianie pomiędzy wykonaniami funkcji. Dzięki temu każde wykonanie funkcji z pewnymi argumentami da ten sam wynik, niezależnie od tego, jaki kod zostanie wykonany w międzyczasie.

W Reduksie reducer musi być funkcją czystą. Innymi słowy, może wykonywać operacje wyłącznie w oparciu o otrzymane argumenty oraz wartości stałych zdefiniowanych poza reducerem. Nie może korzystać ze zmiennych zdefiniowanych poza reducerem, ani z żadnych funkcji nieczystych (nawet jeśli zostały zapisane w stałych).

Ta zasada sprawia, że reducer działa w bardzo przewidywalny sposób. Dla przykładu, jeśli stan aplikacji ma początkowe wartości, to dodanie kolumny o nazwie Restaurants i listId równym 0, zawsze będzie miało ten sam efekt. Nie ważne, na jakim urządzeniu otworzyliśmy stronę, czy kolumna jest dodawana przez Creator, czy inny komponent, etc.

Może Ci się wydawać to zbędną i nudną teorią, której i tak mamy sporo w tym module – te zasady jednak pozwolą Ci uniknąć mnóstwa błędów. Dlatego warto zapamiętać (albo nawet zapisać) zasady, które musi spełniać każdy reducer.

Reducer nigdy nie zmienia otrzymanego stanu

Wreszcie, pozostało nam przyjrzeć się sytuacji, w której nasz reducer z pliku columnsRedux.js otrzyma akcję typu ADD_COLUMN. Wtedy zwróci nową tablicę, w której znajdzie się rozpakowany dotychczasowy stan, oraz dodany nowy obiekt. W tym obiekcie rozpakowany zostanie payload akcji oraz stworzone zostanie id kolumny (za pomocą biblioteki shortid, o której powiemy za chwilę).

Możesz pomyśleć, że cała ta operacja jest bez sensu – przecież wystarczyłoby użyć metody push, aby dodać nowy element do statePart. To by jednak złamało trzecią zasadę, która mówi, że reducer nie zmienia otrzymanego stanu.

Wynika to z tego, że magazyn potrzebuje porównać stan sprzed uruchomienia reducerów, ze stanem otrzymanym po ich wykonaniu. W oparciu o to, co zmieniło się w stanie, magazyn będzie mógł wykonać odpowiednie akcje – np. poinformować komponenty o zmianie wartości.

Dlatego zawsze musimy dbać o to, aby zwracany stan – jeśli ma być jakkolwiek inny od otrzymanego w argumencie – był zupełnie nowym obiektem (lub tablicą).

Dobra wiadomość jest taka, że jeśli statePart jest tablicą, to przy dodawaniu elementów możemy wykorzystać destrukturyzację (rozpakowanie tablicy za pomocą ...statePart), a przy innych manipulacjach możemy wykorzystywać metody map, filter czy slice. Szczególnie w przypadku tej ostatniej, uważaj, aby nie pomylić jej z metodą splice, która modyfikuje tablicę, na której jest wykonywana!

Wykorzystanie pakietu shortid

Ten pakiet pozwala nam na generowanie krótkich, losowych identyfikatorów. Dzięki niemu możemy być spokojni, że identyfikatory poszczególnych kart nie będą się powtarzać.

W naszej aplikacji nie potrzebujemy, aby identyfikatory kart czy kolumn były inkrementowane, czyli były kolejnymi liczbami. Dlatego łatwiej nam będzie zastosować losowe identyfikatory, niż sprawdzać, jaki id nie był jeszcze użyty.

Wykorzystanie pliku z akcjami i reducerem

W tym submodule potrzebowaliśmy przeanalizować trochę kodu i wytłumaczyć spory zakres materiału. Nie przejmuj się jednak, jeśli to wszystko nie jest dla Ciebie jeszcze do końca jasne – śmiało wracaj do powyższych rozdziałów przy dalszej nauce, aby przypomnieć sobie działanie akcji czy reducerów.

Dobra wiadomość jest taka, że struktura tego pliku będzie dla Ciebie ściągawką, która pomoże Ci wdrażać kolejne funkcjonalności. Zaglądając do pliku columnsRedux.js, szybko przypomnisz sobie, że najczęściej potrzebujesz jakiegoś selektora danych ze stanu aplikacji, a od wykonywania akcji potrzebujesz ustawić nazwę reducera, typy i kreatory akcji, oraz reducer.

Zadanie: Dodawanie nowych kolumn i kart

No, ale dość już analizowania kodu – czas zakasać rękawy i zastosować zdobytą wiedzę!

W tym submodule dodaliśmy do List funkcjonalność dodawania kolumn za pomocą komponentu Creator. Teraz Twoim zadaniem jest dodanie do Column funkcjonalności dodawania kart, również za pomocą komponentu Creator.

Pamiętaj, że w tym zadaniu możesz mocno opierać się o pliki List.js, ListContainer.js oraz columnsRedux.js. Na ich podstawie zmienisz i/lub stworzysz pliki Column.js, ColumnContainer.js oraz cardsRedux.js. Nie zapomnij też dodać reducera kart do store.js!

Nie ma tu żadnej nowej funkcjonalności ani żadnych haczyków – wystarczy, że zmiany wykonane w tym submodule dla List zastosujesz z głową dla Column.

Powodzenia!

16.5. Filtrowanie wyświetlonych kart

Zmigrowaliśmy już całą aplikację na Reduksa, wyświetlają się kolumny i karty, działa też ich dodawanie. Teraz wreszcie możemy przejść do implementowania nowych funkcjonalności!

Filtrowanie kart będzie się odbywać za pomocą pola tekstowego – w tym celu stworzymy nowy komponent. Zaczynamy od pobrania paczki z plikami:

Pobierz pliki magazynu

Rozpakuj pliki Search.js, Search.scss i SearchContainer.js do katalogu src/components/Search, a plik searchStringRedux.js – do src/redux.

Komponent Search stworzyliśmy na podstawie komponentu Creator, wprowadzając jedynie kilka zmian. Dodaliśmy też do niego kontener, który mapuje cztery propsy:

  • searchString to props zawierający aktualną frazę wyszukania, pobraną ze stanu aplikacji,
  • countVisible zawierający liczbę kart widocznych po przefiltrowaniu,
  • countAll zawierający liczbę wszystkich kart,
  • changeSearchString to dispatcher wysyłający akcję, która ma na celu zmianę searchString w stanie aplikacji.

Dodaliśmy też style tego komponentu, więc wygląda na to, że wszystko gotowe, prawda? Niestety, nie będzie tak łatwo! ;)

Redux dla searchString

Przygotowaliśmy dla Ciebie komponent Search, ale plik searchStringRedux.js z powyższej paczki jest prawie pusty. Zawiera tylko domyślny reducer, który zawsze zwraca statePart otrzymany jako argument.

Zmiany w store.js

Zanim zaczniemy uzupełniać plik searchStringRedux.js, musimy dodać dwie zmiany w pliku src/redux/store.js. Po pierwsze, musimy do niego zaimportować reducer z searchStringRedux.js, oraz dodać go do obiektu reducers pod kluczem searchString.

Drugą zmianą w store.js będzie dodanie searchString z wartością '' (pusty ciąg znaków) do obiektu initialState.

Dzięki tym zmianom dodaliśmy searchString do stanu początkowego oraz wskazaliśmy magazynowi, skąd ma wziąć reducer tej właściwości stanu.

Selektory

Wracamy do pliku searchStringRedux.js. Pierwszy komentarz podpowiada nam, aby zacząć od selektorów. W pliku SearchContainer.js, w funkcji mapStateToProps widzisz trzy mapowania propsów. Każde z nich wykorzystuje inną funkcję, przekazując jej cały stan aplikacji.

Musimy w takim razie stworzyć trzy funkcje w pliku searchStringRedux.jsgetSearchString, countVisibleCards i countAllCards.

Funkcja getSearchString musi zwracać wartość właściwości searchString, zapisanej bezpośrednio w stanie aplikacji. Z tą funkcją poradzisz sobie samodzielnie, prawda? ;)

Druga z funkcji – countVisibleCards – będzie nieco bardziej skomplikowana. Musi ona wyszukać karty pasujące do frazy searchString, a następnie je policzyć. Zacznijmy więc od prostszej funkcji – countAllCards. Będzie ona wyglądała następująco:

export const countAllCards = ({cards}) => cards.length;

Funkcja countVisibleCards będzie działała w ten sam sposób, musimy tylko dodać do niej filtrowanie kart za pomocą metody filter:

.filter(card => new RegExp(searchString, 'i').test(card.title))

Dodaj ten fragment kody przed .length, a funkcja powinna działać bez problemu. Pamiętaj tylko, aby w argumencie funkcji strzałkowej (czyli przed strzałką) dodać searchString do destrukturyzacji argumentu!

Selektory mamy gotowe, więc zanim przejdziemy dalej, przetestujemy, czy dotychczasowe zmiany działają!

Otwórz plik App.js i zaimportuj w nim komponent Search z pliku SearchContainer.js. Następnie w kodzie JSX, po nagłówkach, dodaj ten komponent bez propsów.

Jeśli wszystko poszło dobrze, pole wyszukiwania powinno pojawić się na stronie. Oczywiście, na razie nie będzie działać, ponieważ nie zdefiniowaliśmy jeszcze akcji. Jak w takim razie możemy przetestować jego działanie?

Możemy tymczasowo zmienić początkowy stan aplikacji! Otwórz plik store.js i w obiekcie initialState zmień wartość właściwości searchString na 'ter'. Po odświeżeniu strony, w polu wyszukiwania powinien pojawić się tekst "ter", a obok guzika wyszukiwania powinny być wyświetlone dwie liczby. Jeśli pracujesz na danych z przygotowanego przez nas dataStore.js, będzie to "2 / 6", które informuje nas, że znaleziono dwie karty zawierające fragment tekstu "ter", spośród 6 kart istniejących w stanie aplikacji.

Zostawmy na razie taki początkowy stan aplikacji – będzie nam wygodniej testować rozwiązanie poniższego zadania.

Zadanie: Dokończenie implementacji

Twoim zadaniem jest dokończenie funkcjonalności wyszukiwania. Kolejne kroki opisujemy poniżej.

Krok 1: zmiana selektora kart

Zacznijmy od zmiany w pliku src/redux/cardsRedux.js. Musimy zmodyfikować selektor kart, czyli funkcję getCardsForColumn, aby uwzględniała filtrowanie. W tej chwili funkcja filtrująca, znajdująca się w getCardsForColumn, zwraca wynik porównania card.columnId == columnId – Twoim zadaniem jest dodanie drugiego warunku (połączonego operatorem oraz &&), który będzie sprawdzał czy tytuł karty pasuje do wyszukiwanej frazy, czyli: new RegExp(searchString, 'i').test(card.title). Pamiętaj o uwzględnieniu searchString w destrukturyzacji argumentu funkcji strzałkowej!

Po wykonaniu tego kroku na stronie powinny się wyświetlać wyłącznie karty pasujące do searchString, który wpisaliśmy w initialState w pliku store.js.

Nie zapomnij zmienić z powrotem tej wartości na pusty ciąg znaków!

Krok 2: typ i kreator akcji

Pozostałe kroki wykonamy w pliku searchStringRedux.js.

Zaczynamy od dodania stałej reducerName, nadania jej właściwej wartości, oraz skopiowania funkcji createActionName z innego pliku ...Redux.js.

Następnie musimy zdefiniować typ akcji, np. CHANGE, oraz stworzyć creator tej akcji. Wzoruj się na pozostałych plikach w tym samym katalogu.

Krok 3: uzupełnienie reducera

Wreszcie, pozostaje uzupełnienie reducera. Na razie zawsze zwraca otrzymany fragment stanu. W tym przypadku wspomnianym fragmentem stanu jest state.searchString, który jest ciągiem znaków. W takim razie, jeśli reducer wykryje akcję zmiany wyszukiwanej frazy, ma zwrócić payload tej akcji. Wynika to z faktu, że – jak możesz sprawdzić w SearchContainer.js – jako payload ustawiamy po prostu nową wartość tej właściwości.

Po wykonaniu tego kroku filtrowanie kart powinno być już w pełni funkcjonalne! Jeśli wpiszesz coś do pola wyszukiwania i klikniesz guzik z ikoną lupy, karty powinny zostać przefiltrowane. Aby wyświetlić wszystkie karty, wystarczy usunąć zawartość pola wyszukiwania i ponownie wcisnąć guzik.

16.6. Podsumowanie

Nauka Reduksa mogło być dla Ciebie ciężkim przejściem i możesz czuć, że nie do końca rozumiesz, jak go stosować. Nie przejmuj się, jest to zagadnienie, które wymaga trochę wprawy i zaimplementowania go kilka razy. Będziesz mieć do tego jeszcze niejedną okazję.

Mamy jednak nadzieję, że ten moduł pozwolił Ci postawić pierwsze reduksowe kroki, zrozumieć jego ogólne założenia, oraz docenić jego rolę w aplikacji reactowej.

Zanim przejdziemy do ostatniego zadania w tym module – publikacji projektu na Heroku – wrócimy jeszcze do obiecanego omówienia tworzenia reduksowego magazynu.

Omówienie store.js

Rozpoczynając naukę Reduksa, chcieliśmy skupić się nad jego wykorzystaniem w projekcie. Dlatego pominęliśmy wyjaśnienie stworzenia magazynu w pliku store.js. Teraz możemy krótko go sobie omówić – krótko, ponieważ nie jest on wcale skomplikowany, kiedy znamy już zasadę działania Reduksa.

Tuż po importach, o których wspomnimy poniżej, deklarujemy początkowy stan aplikacji. To bardzo ważny krok, ponieważ jest on jednocześnie dokumentacją struktury stanu aplikacji. Dlatego zdecydowaliśmy się na wypisanie wszystkich jego właściwości, zamiast rozpakowania całego initialStoreData, zaimportowanego z dataStore.js.

Następnym elementem w tym pliku jest obiekt reducers, który zawiera listę reducerów zajmujących się poszczególnymi fragmentami stanu aplikacji. Zaimportowaliśmy je z plików src/redux/...Reducer.js. Jak za chwilę się dowiesz, Redux wspiera tylko jeden reducer, obsługujący cały magazyn. W praktyce jednak bardzo często rozbija się go na mniejsze reducery – dlatego też zdecydowaliśmy się na taką architekturę.

Nasze cząstkowe reducery są łączone w jeden za pomocą funkcji combineReducers, zaimportowanej z pakietu redux. Ta funkcja wymaga jednak reducera dla każdej właściwości stanu aplikacji – dlatego przed połączeniem reducerów cząstkowych zdefiniowanych w obiekcie reducers, wykorzystujemy pętlę (a konkretniej, metodę forEach) iterującą po kluczach obiektu initialState. Jeśli dla danego klucza (np. app) nie został zdefiniowany reducer cząstkowy, to tworzymy go i dodajemy do obiektu reducers. Jest to jednak absolutnie minimalna forma reducera – funkcja strzałkowa zwracająca otrzymany fragment stanu aplikacji.

Po tej operacji łączymy wszystkie reducery cząstkowe w stałej storeReducer, a następnie przechodzimy do stworzenia magazynu za pomocą funkcji createStore, zaimportowanej z pakietu redux. Jedynym obowiązkowym argumentem jest reducer, którym w naszym przypadku jest połączony storeReducer. W drugim argumencie przekazujemy początkowy stan, a w trzecim – wyrażenie skopiowane z instrukcji implementacji pakietu redux-devtools-extension, czyli pluginu pozwalającego nam na podgląd magazynu w narzędziach developerskich przeglądarki.

Wreszcie, w ostatniej linii eksportujemy store, abyśmy mogli wykorzystać go jako props komponentu Provider, użytego w index.js.

Jak widzisz, nie jest to bardzo skomplikowane do zrozumienia, kiedy już wiesz, czym są reducery. Jednak poznanie reducerów wymaga również zrozumienia akcji oraz mapowania dispatcherów na propsy – dlatego nie mogliśmy wyjaśnić tego pliku na samym początku nauki Reduksa.

Zadanie: Publikacja projektu

Na koniec zadanie, które powinno być już samą przyjemnością – publikacja projektu na Heroku!

Tym razem nasza aplikacja nie korzysta z json-server, więc zainstaluj i wykorzystaj http-server zamiast niego. Task, który uruchamia serwer, będzie bardzo prosty – http-server dist. Pamiętaj, że nazwa tego taska musi odpowiadać tej, którą podasz w pliku Procfile.

Po opublikowaniu projektu pochwal się nim Mentorowi!

16.7. Quiz powtórkowy

Na koniec tego modułu przygotowaliśmy dla Ciebie quiz powtórkowy. Pomoże Ci on powtórzyć wiedzę z poprzednich modułów.

Odpowiedzi tego quizu nie są nigdzie zapisywane, więc są tylko do Twojej wiadomości. Ten quiz ma Ci posłużyć jako pomoc w nauce – dlatego pod każdym pytaniem znajdziesz guzik, który sprawdzi poprawność Twoich odpowiedzi oraz poda Ci wyjaśnienie zagadnienia poruszanego w tym pytaniu.

1. Wybierz prawdziwe zdania dotyczące Reacta:

Wyjaśnienie

Zacznijmy od podstaw – React jest biblioteką, a nie frameworkiem. Główna różnica między tymi dwoma podejściami to poziom kontroli developera nad kodem projektu.

Biblioteka jest zestawem funkcji, obiektów, etc., które developer wykorzystuje tam, gdzie tego potrzebuje. Architektura całego projektu może być jednak dowolnie zaprojektowana. Tymczasem framework narzuca developerowi sposób organizacji kodu – np. nazewnictwo plików, funkcji czy metod. W przypadku używania frameworka developer musi się podporządkować.

To ważna różnica, ponieważ w czasie kursu uczysz się nie tylko używania Reacta, ale również dobrych praktyk tworzenia aplikacji reactowej. Musisz jednak wiedzieć, że o ile framework narzucałby nam strukturę np. katalogów i plików, to React (biblioteka) pozwala nam na sporą dowolność. Dlatego poza kursem możesz się spotkać z zupełnie innymi podejściami np. do struktury projektu.

Przechodząc do komponentów, mogą one mieć propsy i stan. Propsy są przekazywane przez rodzica i mogą być przez niego zmieniane. Komponent może odczytywać swoje propsy, ale nie może ich zmieniać. W przypadku stanu jest w dużej mierze odwrotnie – to komponent tworzy i zmienia swój stan, a rodzic nie może nawet odczytywać stanu.

Zarówno zmiana propsów przez rodzica komponentu, jak i zmiana stanu przez sam komponent, spowoduje wywołanie metody render. Nie musisz się jednak obawiać tego, że ta metoda będzie wykonywana wielokrotnie – React nie będzie niepotrzebnie zamieniał elementów DOM na identyczne. Zmieni tylko to, co jest niezbędne – np. tylko klasę, wartość czy tekst w jakimś komponencie.

2. Wybierz prawdziwe zdania dotyczące JSX

Wyjaśnienie

JSX jest wygodniejszym sposobem zapisu struktury elementów i komponentów w React.js. Wygląda podobnie do kodu HTML, ale zdecydowanie nie jest tym samym.

Jedną z kluczowych różnic jest to, że elementom znanym z HTML nie możemy nadawać atrybutu class – zamiast niego użyjemy atrybutu className. Drugą różnicą jest fakt, że za pomocą JSX de facto tworzymy elementy reactowe, więc możemy używać zarówno tagów znanych z HTML, jak i komponentów reactowych.

Kolejna różnica, która może wymagać zmiany przyzwyczajeń, to domykanie tagów. Szczególnie w przypadku elementów takich jak <img /> czy <input />, w kodzie HTML mogliśmy pominąć znak /, ale w JSX musimy go używać w każdym przypadku, kiedy nie ma osobnego tagu zamykającego (np. <div></div>). Dotyczy to także wykorzystania komponentów reactowych – dlatego możemy użyć <Search></Search> lub <Search />, ale nie możemy wykorzystać tagu otwierającego bez jego zamknięcia.

Wreszcie ostatnia z najważniejszych kwestii – JSX nie jest natywnie wspierany przez przeglądarki, więc wymaga użycia biblioteki Babel. To właśnie ta biblioteka zajmie się skonwertowaniem kodu JSX na zwykły JavaScript, zanim skrypt zostanie wykonany.

3. Czym różnią się zmienne let i stałe const od zmiennych var?

Wyjaśnienie

Zacznijmy od przypomnienia, że zmienne var mają zasięg funkcji, w której zostały zadeklarowane. Są one również hoistowane, co oznacza, że teoretycznie nie jest błędem wielokrotne deklarowanie tej samej zmiennej w tym samym zakresie.

Inaczej działają zmienne let oraz stałe const. Ich zakresem jest blok { } i nie mogą być wielokrotnie deklarowane.

Stałe const różnią się od zmiennych let tylko tym, że stałej można przypisać wartość tylko w momencie deklaracji, i nie można jej już potem zmieniać. Dotyczy to jednak tylko przypisania wartości za pomocą znaku = – czyli jeśli w stałej umieścimy tablicę, to nie możemy potem przypisać do tej samej stałej innej tablicy (lub obiektu, liczby, tekstu), ale będziemy mogli zmieniać zawartość tej tablicy.

16.8. Egzamin sprawdzający

Minęła już spora część kursu, po której warto sprawdzić swoją wiedzę. W związku z tym zapraszamy Cię do wzięcia udziału w egzaminie sprawdzającym. Zakres materiału na egzaminie nie obejmuje Reacta!

W ramach Twojego pakietu wsparcia w poszukiwaniu pracy, podejście do egzaminu sprawdzającego jest opcjonalne.

Egzamin ten ma na celu sprawdzenie, jaki masz aktualny poziom wiedzy, co nie stanowi już dla Ciebie problemu, a co ewentualnie wymaga powtórzenia. Potraktuj go jako rodzaj treningu, który przygotuje Cię do zdania egzaminu końcowego z jak najlepszym wynikiem.

Opis platformy egzaminacyjnej

Egzamin odbywa się na platformie rekrutacyjnej Devskiller. Pracodawcy często korzystają z tego rodzaju platform testowych w trakcie rekrutacji programistów. Wybraliśmy to rozwiązanie, aby lepiej przygotować Cię do procesów rekrutacyjnych, z którymi spotkasz się już niedługo. Egzamin odbywa się przez internet i wykonujesz go samodzielnie (nie łączysz się z żadnym Mentorem ani Egzaminatorem).

Etapy egzaminu

  1. Pytania testowe, część 1 - 7 pytań po 3 minuty (w sumie 21 minut),
  2. Przerwa - 10 minut,
  3. Pytania testowe, część 2 - kolejne 7 pytań po 3 minuty (w sumie 21 minut),
  4. Przerwa - 10 minut,
  5. Przykładowe zadanie praktyczne - 30 minut,
  6. Zadanie praktyczne z CSS - 20 minut,
  7. Przerwa - 10 minut,
  8. Zadanie praktyczne z JS - 30 minut.

Wszystkie czasy podane powyżej są limitem czasu. Każde pytanie, zadanie czy przerwę możesz zakończyć szybciej. Z tego względu, egzamin nie powinien zająć Ci więcej niż 1,5 godziny, ale na wszelki wypadek zarezerwuj sobie co najmniej 2,5 godziny.

Rodzaje zadań

1. Pytania testowe, w których należy zaznaczyć wszystkie poprawne odpowiedzi (jedną lub więcej). Ten rodzaj zadań ćwiczyliśmy do tej pory w quizach powtórkowych.

image

2. Zadania praktyczne, w których otrzymasz fragment kodu do poprawienia lub uzupełnienia. Pierwsze z zadań praktycznych jest przykładowe — nie otrzymasz za nie żadnych punktów, ale będziesz mieć okazję zaznajomić się z edytorem oraz strukturą projektu. Pamiętaj, że m.in. to przykładowe zadanie znalazło się w poprzednim module, w materiałach przygotowujących do egzaminu.

image

Przydatne informacje

  1. Jeśli chcesz lepiej przygotować się do egzaminu, wróć do quizów powtórkowych we wcześniejszych modułach. Rozwiąż też zadania udostępnione w poprzednim module (submoduł "Przygotowanie do egzaminu").
  2. Zadbaj o czas i miejsce, które pozwolą Ci się skupić na egzaminie. Jeśli to możliwe, wycisz swój telefon i uprzedź bliskich, aby w tym czasie Ci nie przeszkadzali.
  3. Nie można wracać do wcześniejszych zadań, więc uważnie czytaj polecenia i zastanów się nad odpowiedzią, zanim przejdziesz do kolejnego zadania. Każde zadanie ma limit czasu.
  4. W niektórych pytaniach testowych jest kilka poprawnych odpowiedzi — zaznacz wszystkie.
  5. Nie przerywaj egzaminu — możesz do niego podejść tylko raz. W trakcie egzaminu możesz robić przerwy tylko pomiędzy etapami wymienionymi poniżej.
  6. Jeśli przypadkiem zamkniesz egzamin, możesz ponownie go otworzyć za pomocą linka w mailu z zaproszeniem na egzamin.
  7. Wyniki otrzymasz w ciągu tygodnia od podejścia do egzaminu.

Zapis na egzamin

Do przystąpienia do egzaminu niezbędne jest zapisanie się na egzamin.

Pamiętaj:

  • na egzamin możesz zapisać się tylko raz,
  • link z zaproszeniem otrzymasz najpóźniej następnego dnia roboczego,
  • zaproszenie będzie ważne przez dwa tygodnie.

Przed zapisaniem się na egzamin:

  1. zaplanuj czas, w którym są najmniejsze szanse, że ktoś będzie Ci przeszkadzał,
  2. zaplanuj miejsce, które pozwoli Ci skupić się na egzaminie,
  3. zaplanuj sprzęt (laptop, internet), który nie będzie powodował problemów.

Najpóźniej dzień przed planowanym podejściem do egzaminu wypełnij poniższy formularz.


Powodzenia!

16.9. Dla chętnych

W tym module spotkaliśmy się ze sporą ilością nowych informacji. Zmieniły one sposób, w jaki korzystamy z Reacta, którego też dopiero co poznaliśmy. Dlatego pozostała część modułu jest opcjonalna – wykonaj ją, kiedy zechcesz. Pozwoli Ci ona przećwiczyć zarówno korzystanie z Reacta, jak i z Reduksa.

Omówimy sobie zawartość pliku store.js, a następnie zaimplementujemy przeciąganie kart, które pozwoli nie tylko na zmianę ich kolejności, ale też przenoszenie do innych kolumn. Zaczniemy jednak od paru propozycji samodzielnej kontynuacji pracy nad tym projektem.

Dalszy rozwój projektu

Pierwszą rzeczą, którą warto by było zrobić, jest zrefaktorowanie większości komponentów klasowych na funkcyjne. Po wprowadzeniu Reduksa, ze stanu komponentu korzystają jedynie Creator i Search. Wszystkie inne komponenty powinny być funkcyjne, więc do zmiany są: List, Column i Card.

Nie wymieniliśmy tutaj komponentu App, ponieważ w następnym module będziemy wykorzystywać jego stan.

Kolejne zmiany, które możemy wprowadzić, skupione będą na rozszerzaniu funkcjonalności aplikacji:

  1. Obecnie komponent Search tylko filtruje karty wyświetlane w kolumnach – możesz zaimplementować nowy komponent SearchResults, który byłby wyświetlany zamiast list w App, i zawierałby wszystkie znalezione karty. Każda z nich musiałaby zawierać informację o liście i kolumnie, w których się znajduje. Dzięki temu wyniki wyszukiwania byłyby wyświetlane jako jedna, spójna lista wyników.
  2. W poprzednim module, jako zadanie dla chętnych, proponowaliśmy stworzenie guzika hamburgera, które po kliknięciu wyświetli menu zawierające wszystkie listy. Teraz możesz rozwinąć ten komponent tak, aby po kliknięciu któregoś tytułu listy w tym menu, na stronie była wyświetlona wyłącznie ta lista. W tym samym menu warto dodać też link "All", który wyświetli wszystkie listy. Pisząc tę funkcjonalność, możesz wzorować się na tym, w jaki sposób komponent Search filtruje wyświetlane karty.
  3. Analogicznie do omówionego poniżej przeciągania kart w kolumnach (i pomiędzy nimi), możesz zaimplementować przeciąganie kolumn w listach (i pomiędzy nimi).

To tylko kilka propozycji – jeśli za szybko sobie z nimi poradzisz, z pewnością wymyślisz kolejne sposoby rozwoju projektu. ;)

Implementacja przenoszenia kart

Wcześniej w tym module wspomnieliśmy o tym, że można by było dodać funkcjonalność przeciągania kart pomiędzy kolumnami. To brzmi ciekawie, ale też jest to całkiem skomplikowane przedsięwzięcie. Dlatego, zamiast zostawiać Cię z poczuciem niedosytu czy frustracją przy próbie samodzielnej implementacji, przygotowaliśmy dla Ciebie instrukcję wdrożenia tej funkcjonalności.

Naszym celem jest umożliwienie przeciągania kart – zarówno w ramach jednej kolumny, aby zmieniać ich kolejność, jak i przenoszenia kart do innych kolumn. Ta funkcjonalność będzie się składała z dwóch części:

  1. Warstwa widoku, czyli obsługa przeciągania kart.
  2. Warstwa logiki, czyli sposób zapisywania kolejności kart.

Oczywiście, będą one ze sobą połączone – w momencie przeniesienia karty widok będzie informował logikę o tym, którą kartę przenieść, oraz skąd i dokąd ma być przeniesiona. Jak już zapewne się domyślasz, warstwa logiki będzie zrealizowana z wykorzystaniem reduksowych akcji i obsłużona w reducerze.

Warstwa widoku

Do przeciągania kart pomiędzy kolumnami wykorzystamy plugin stworzony przez firmę Atlassian, czyli właściciela takich rozwiązań jak Jira, BitBucket, czy Trello. Wspominamy o tym, ponieważ możemy śmiało zakładać, że rozwiązania stworzone i publikowane przez gigantów branży, takich jak Facebook czy Atlassian, będą sprawnie działały, a do tego będą opatrzone niezłą dokumentacją.

Zaczynamy od zainstalowania pluginu react-beautiful-dnd za pomocą komendy:

npm install -S react-beautiful-dnd

Ucząc się Reacta poznajemy i tworzymy komponenty – nic więc dziwnego, że pluginy tworzone z myślą o Reakcie również będziemy wykorzystywać jako komponenty. W przypadku react-beautiful-dnd będziemy mieli do czynienia z trzema komponentami:

  1. DragDropContext – to kontener, który będzie zawierał całą część aplikacji, w której funkcjonalność drag-n-drop ma być dostępna (niekoniecznie dla wszystkich elementów),
  2. Droppable – to kontener, zawierający elementy, które można przenosić,
  3. Draggable – to element, który można przeciągać (od angielskiego drag) i upuszczać (drop) w kontenerach Droppable.

W naszym przypadku poszczególne karty będą Draggable, a kolumny – Droppable. Co w takim razie powinno znaleźć się w DragDropContext? Kod aplikacji przewiduje, że kolejne listy będą wyświetlać się jedna pod drugą i nic nie stoi na przeszkodzie, aby przeciągać karty pomiędzy kolumnami należącymi do różnych list. W takim razie wszystkie listy muszą znaleźć się w DragDropContext.

Komponent DragDropContext

Otwórz plik App.js i znajdź ten fragment kodu:

{lists.map(listData => (
  <List key={listData.id} {...listData} />
))}

Musimy zamknąć go w komponencie DragDropContext. Najpierw jednak zaimportujemy go z pakietu react-beautiful-dnd:

import {DragDropContext} from 'react-beautiful-dnd';

A teraz zmienimy znaleziony wcześniej fragment kodu JSX na następujący:

<DragDropContext onDragEnd={moveCardHandler}>
  {lists.map(listData => (
    <List key={listData.id} {...listData} />
  ))}
</DragDropContext>

Jak widzisz, dodaliśmy dla tego komponentu jednego propsa – onDragEnd – który jest obowiązkowy. Musi zawierać funkcję, wykonywaną po zakończeniu przeciągania elementu Draggable. Dodajmy więc funkcję, która będzie wyświetlała w konsoli obiekt otrzymywany z pluginu. Najlepiej będzie dodać tę funkcję w metodzie render, tuż przed linią zawierającą słowo return.

const moveCardHandler = result => {
  console.log(result);
};

Na tym etapie możemy sprawdzić w narzędziach developerskich, na zakładce React, że komponent DragDropContext został dodany do App. Warto też sprawdzić, czy w konsoli nie pojawiły się żadne błędy.

Komponent Droppable

Teraz czas na drugi z komponentów tego pluginu – otwórz plik Column.js i zaimportuj w nim komponent Droppable. Następnie znajdź poniższy fragment kodu JSX, analogiczny do zmienianego przed chwilą w App.js:

{cards.map(cardData => (
  <Card key={cardData.id} {...cardData} />
))}

Tym razem jednak zmian będzie troszkę więcej, ale wykonamy je po kolei, aby się nie pogubić. Najpierw wrapujemy ten fragment kodu w komponent Droppable, zawierający propsa droppableId:

<Droppable droppableId={id}>
  {cards.map(cardData => (
    <Card key={cardData.id} {...cardData} />
  ))}
</Droppable>

Props droppableId ma mieć wartość równą id karty. Wynika to z tego, że plugin potrzebuje mieć unikalny identyfikator kontenera zawierającego elementy, które można przenosić. Nie będzie to dla nas problemem, ponieważ i tak każda karta w naszej aplikacji ma swoje id. Abyśmy jednak mogli go użyć w tym miejscu, musimy dodać je do destrukturyzacji propsów w pierwszej linii metody render. Nie zapomnij też dodać go do propTypes z typem PropTypes.string.

Komponent Droppable oczekuje pojedynczego elementu, więc musimy mapowanie cards zamknąć w divie. Od razu dodamy mu klasę, bo pewnie będziemy chcieli dodać dla niego jakieś style.

<Droppable droppableId={id}>
  <div className={styles.cards}>
    {cards.map(cardData => (
      <Card key={cardData.id} {...cardData} />
    ))}
  </div>
</Droppable>

To jeszcze nie koniec – ten plugin wymaga, aby zawartość tego komponentu była zamknięta w funkcji, otrzymującej jeden argument. Zgodnie z dokumentacją, nazwiemy go provided.

<Droppable droppableId={id}>
  {provided => (
    <div className={styles.cards}>
      {cards.map(cardData => (
        <Card key={cardData.id} {...cardData} />
      ))}
    </div>
  )}
</Droppable>

Ostatni krok – musimy wykorzystać argument provided do przekazania kilku propsów głównemu elementowi w funkcji, czyli divowi. Większość z nich znajduje się w obiekcie provided.droppableProps, ale oprócz rozpakowania tego obiektu, musimy też dodać atrybut ref.

<Droppable droppableId={id}>
  {provided => (
    <div
      className={styles.cards}
      {...provided.droppableProps}
      ref={provided.innerRef}
    >
      {cards.map(cardData => (
        <Card key={cardData.id} {...cardData} />
      ))}

      {provided.placeholder}
    </div>
  )}
</Droppable>

Nie tłumaczymy dokładnie, do czego służą te propsy, ponieważ nie musimy tego wiedzieć. Na etapie wdrożenia pluginu po prostu podążamy za instrukcją autora pluginu – którą dla Ciebie przełożyliśmy na nasz projekt. ;)

Zanim skończymy pracę nad komponentem Column, dodajemy jeszcze style, które pozwolą na przenoszenie karty do pustej kolumny:

.cards {
  padding: ($base-size / 2) 0;
}

Ten komponent jest gotowy, przechodzimy do ostatniego!

Komponent Draggable

Jak już wiesz, ten komponent oznacza element, który będzie przeciągany. Otwieramy w takim razie plik Card.js i importujemy Draggable z react-beautiful-dnd. Do propTypes dodajemy id jako tekst oraz index jako numer.

W metodzie render musimy dodać id i index do destrukturyzacji propsów, a następnie zmienić kod JSX na następujący:

<Draggable draggableId={id} index={index}>
  {(provided) => (
    <article
      className={styles.component}
      {...provided.draggableProps}
      {...provided.dragHandleProps}
      ref={provided.innerRef}
    >
      {title}
    </article>
  )}
</Draggable>

Nie omawiamy go krok po kroku, ponieważ jest zbudowany bardzo podobnie do tego, którego użyliśmy w komponencie List. Są jednak dwie istotne różnice:

  • index to props, który zawiera numer porządkowy elementu (omówimy go za chwilę),
  • wyrażenie {...provided.dragHandleProps} umieściliśmy w <article>, ponieważ karta ma tylko jeden element, ale gdybyśmy chcieli, żeby tylko fragment karty dało się chwycić i przesunąć, moglibyśmy to wyrażenie przenieść do innego elementu, wewnątrz <article>.

Wróćmy jednak do indeksu – każdy element Draggable musi posiadać numer porządkowy, czyli wiedzieć, na której jest pozycji w swoim rodzicu. Zdecydowaliśmy się, że w naszym projekcie będziemy tę liczbę przechowywać w stanie aplikacji, jako właściwość każdej z kart.

Uzupełnienie stanu początkowego

W takim razie musimy otworzyć plik dataStore.js i do każdej karty dodać właściwość index. Jej wartością musi być liczba, a kolejne karty w tej samej kolumnie mają mieć w tej właściwości kolejne liczby, rozpoczynając od zera. Innymi słowy, pierwsza karta w pierwszej kolumnie będzie miała index: 0, kolejna – 1, a pierwsza karta w następnej kolumnie – również index: 0.

Po tej zmianie możemy już zajrzeć na podgląd naszej aplikacji w przeglądarce – przesuwanie kart już działa... częściowo. Karty można przesuwać za pomocą myszy (a nawet klawiatury, za pomocą klawisza Tab, strzałek oraz spacji). Tuż po upuszczeniu karty, jednak wraca ona na wcześniejszą pozycję. Nie przejmuj się – tak miało być!

Wynika to z tego, że po zakończeniu przeciągania karty, kolumna jest na nowo renderowana, a więc z powrotem wyświetla się to, co znajduje się w stanie aplikacji. Nie mamy jeszcze warstwy logicznej, więc przywracany jest wcześniejszy stan.

To jednak nie problem – najważniejsze, że działa mechanizm drag-n-drop!

Warstwa logiczna

Teraz kiedy możemy już przeciągać karty, wracamy prawie do samego początku implementacji pluginu, kiedy w App.js zdefiniowaliśmy funkcję moveCardHandler. Dodaliśmy do niej na razie console.log, więc możemy teraz sprawdzić w konsoli, jak wygląda obiekt result, czyli argument tej funkcji.

image

Interesują nas w nim:

  • właściwość draggableId, która zawiera id karty,
  • obiekt source, który zawiera id kolumny oraz indeks karty, zanim została przeniesiona,
  • obiekt destination, analogiczny do source, ale prezentujący docelową lokalizację karty.

Sprawdź, co się stanie, kiedy spróbujesz przenieść kartę poza którąkolwiek kolumnę (np. na tytuł listy), oraz sytuację, w której przesuniesz kartę tylko o parę pikseli, aby upuścić ją w tym samym miejscu, w którym była. W tych sytuacjach nie będziemy chcieli wykonywać żadnej akcji, więc będziemy musieli napisać warunek if, który zignoruje te dwie sytuacje (czyli nie wykona kodu w tym bloku if).

Będziemy też chcieli zreorganizować trochę te informacje, aby zamiast draggableId i droppableId używać naszych kluczy stanu karty: id i columnId. W takim razie zmodyfikujmy funkcję moveCardHandler następująco:

const moveCardHandler = result => {
  if(
    result.destination
    &&
    (
      result.destination.index != result.source.index
      ||
      result.destination.droppableId != result.source.droppableId
    )
  ){
    console.log({
      id: result.draggableId,
      dest: {
        index: result.destination.index,
        columnId: result.destination.droppableId,
      },
      src: {
        index: result.source.index,
        columnId: result.source.droppableId,
      },
    });
  }
};

Teraz przy przesuwaniu kart w konsoli powinien się pojawiać obiekt ze zdefiniowanymi przez nas kluczami. Użyty przez nas warunek może wydawać się skomplikowany, ale z pewnością poradzisz sobie z jego zrozumieniem. Pamiętaj tylko, że && oznacza "oraz", a || – "lub". Podzieliliśmy ten warunek na kilka linii i dodaliśmy wcięcia, aby łatwiej było go przeczytać – jednak działałby tak samo, gdyby był w całości zapisany w jednej linii.

Mamy już przygotowany obiekt z potrzebnymi nam informacjami – teraz musimy go jakoś wykorzystać.

Dispatchowanie akcji

Ten krok z powodzeniem wykonasz samodzielnie – wystarczy, że w cardsRedux.js dodasz nowy typ akcji (MOVE_CARD) oraz stworzysz dla niego kreator akcji (createAction_moveCard).

Następnie w AppContainer.js musisz dodać funkcję mapDispatchToProps. Powinna ona mapować do propsa moveCard funkcję strzałkową, która przyjmuje jeden argument (payload), a w rezultacie dispatchuje kreator akcji createAction_moveCard z argumentem tej funkcji strzałkowej (payload).

Zanim przejdziemy dalej, wróćmy do App.js i dodajmy props moveCard do definicji typów (PropTypes.func) oraz do destrukturyzacji propsów w metodzie render. Następnie w funkcji moveCardHandler możemy zmienić console.log na moveCard – zamiast wyświetlać stworzony obiekt, będziemy go przekazywać do tego dispatchera.

Logika zmiany indeksów

Teraz czas na gwóźdź programu, czyli obsługę przenoszenia kart w reducerze!

Nie będzie to takie proste, ponieważ nie wystarczy zmienić indeksu przenoszonej karty. Weźmy za przykład sytuację, kiedy mamy pięć kart o indeksach: 0, 1, 2, 3, 4. Jeśli teraz przeniesiemy czwartą kartę (indeks 3) na drugą pozycję, to przed zmianą indeksów ich kolejność będzie: 0, 3, 1, 2, 4. Z tego wynika, że musimy zmienić indeksy trzech kart, tych które miały indeksy 3, 1 i 2, co możemy zapisać w ten sposób: 0, 3=>1, 1=>2, 2=>3, 4. W rezultacie indeksy będą ponownie w postaci 0, 1, 2, 3, 4, czyli będą kolejnymi liczbami, począwszy od zera.

Sprawa komplikuje się jeszcze bardziej, kiedy przenosimy kartę pomiędzy kolumnami – wtedy musimy zmienić indeksy w dwóch kolumnach!

Całe szczęście, możemy skorzystać z pomocy JS-a, ponieważ posiada on wbudowany mechanizm obsługi zmieniających się indeksów – dzieje się to zawsze, kiedy zmieniamy zawartość jakiejkolwiek tablicy. Dlatego w reducerze, w reakcji na MOVE_CARD, stworzymy sobie tymczasową tablicę, i wykorzystamy ją do obliczenia nowych indeksów.

Nowy case w reducerze

Zacznijmy od dodania do reducera w cardsRedux.js, tuż przed default: w switchu, nowego case'a:

case MOVE_CARD: {
  return statePart;
}

Na początek będziemy zwracać fragment stanu (otrzymany w argumencie reducera) bez żadnych zmian. Dzięki temu nie będą wprowadzane żadne zmiany w stanie, ale będziemy mogli pisać kod i dodawać console.log, aby sprawdzać, czy tworzony kod zmierza we właściwym kierunku.

Może przykuć Twoją uwagę fakt, że tym razem użyliśmy nawiasów klamrowych { }, mimo że w pozostałych case'ach ich nie używaliśmy. Tym razem jednak będziemy potrzebowali napisać nieco dłuższy kod oraz posługiwać się kilkoma stałymi, więc lepiej będzie zamknąć ten kod w bloku { }.

Tworzymy kilka stałych

Na początek ułatwimy sobie trochę pisanie kodu – wykonamy destrukturyzację obiektu action.payload, aby nie powtarzać wielokrotnie tego wyrażenia. Nasz kod będzie dzięki temu bardziej czytelny.

const {id, src, dest} = action.payload;

Następnie przefiltrujemy stan w poszukiwaniu przenoszonej karty oraz wydobędziemy z wyniku filtrowania pierwszą (i jedyną) kartę, podając indeks [0].

const targetCard = statePart.filter(card => card.id == id)[0];

Będziemy też potrzebować tablicy, zawierającej wszystkie karty w tej kolumnie. Posortujemy je od razu wedle indeksu.

const targetColumnCards = statePart.filter(card => card.columnId == dest.columnId).sort((a, b) => a.index - b.index);

Właśnie ta tablica pomoże nam w uzyskaniu nowych indeksów kart. Zobaczmy, jak ona wygląda, za pomocą tego wyrażenia:

console.log(targetColumnCards.map(card => `${card.index}, title: ${card.title}`));

Dlaczego użyliśmy mapowania?

Jest pewna specyficzna kwestia, jeśli chodzi o console.log. Kiedy wyświetlamy w nim jakiś obiekt, a następnie rozwijamy go, to jego wartość jest pobrana dopiero w momencie rozwinięcia. Zobacz na ten przykład, wykonany w konsoli:

image

Jak widzisz, mimo że console.log był wykonany, zanim do obiektu cards dodaliśmy nowy element, to po jego rozwinięciu (przy etykiecie original) zobaczymy również ten dodany element. Dlatego używamy mapowania do stworzenia kopii tablicy w momencie wykonania console.log.

Przy okazji, możemy wykorzystać mapowanie do wyświetlenia tylko niezbędnych informacji, co wykonaliśmy w kodzie powyżej.

Blok warunkowy dla przenoszenia w ramach jednej kolumny

Zaczniemy od rozpatrzenia przypadku, gdy kolumna źródłowa i docelowa jest ta sama – czyli nie przenosimy karty pomiędzy kolumnami. Zamieńmy więc wyrażenie return statePart; na następujący blok if...else:

if(dest.columnId == src.columnId){

} else {
  return statePart;
}
Zmiana kolejności w tymczasowej tablicy

Wewnątrz bloku if musimy najpierw usunąć przenoszoną kartę (targetCard) z tablicy targetColumnCards, a następnie wstawić ją z powrotem, ale w odpowiednie miejsce (dist.index). Do obu tych operacji wykorzystamy metodę splice.

targetColumnCards.splice(src.index, 1);
targetColumnCards.splice(dest.index, 0, targetCard);

Ta metoda, jako pierwszy argument, przyjmuje indeks elementu. Drugim argumentem jest liczba elementów, które mają być usunięte (rozpoczynając od elementu o indeksie z pierwszego argumentu). Dlatego w pierwszej linii powyższego kodu usuwamy jedną kartę, o indeksie src.index. Pamiętaj, że src bierze się z funkcji moveCardHandler w App.js – to tam przekazujemy do tego obiektu index i columnId, które miała przenoszona karta, zanim została przesunięta.

W drugiej linii wybieramy docelową pozycję, usuwamy zero elementów (ale i tak musimy podać ten argument), a jako trzeci argument podajemy kartę, która ma być wstawiona pod tym indeksem.

Sprawdźmy, jak to wpłynęło na indeksy, przenosząc napisany przed chwilą console.log pod te dwie linie z metodą splice. Jeśli odtworzymy wcześniej opisany przykład z pięcioma kartami, to po przeniesieniu czwartej na 2. pozycję, otrzymamy taki komunikat w konsoli:

image

Idąc od lewej, widzimy indeks w tablicy targetColumnCards, indeks karty zapisany w stanie aplikacji, oraz jej tytuł. Dotychczasowe indeksy (zapisane w stanie) ułożyły się w sekwencję, o której pisaliśmy wcześniej – 0, 3, 1, 2, 4. Widzimy więc, że indeksy w tablicy targetColumnCards (pierwsza liczba po lewej na powyższym screenie) będą poprawnymi, nowymi indeksami do zapisania w stanie aplikacji.

Zwrócenie nowego stanu

W tym samym bloku warunkowym dodaj:

return statePart.map(card => {
  const targetColumnIndex = targetColumnCards.indexOf(card);

  if(targetColumnIndex > -1 && card.index != targetColumnIndex){
    return {...card, index: targetColumnIndex};
  } else {
    return card;
  }
});

Zwracamy nową tablicę, będącą mapowaniem tablicy statePart, aby zwrócić wszystkie karty zapisane w stanie aplikacji. Dla każdej z nich sprawdzamy jej pozycję w tablicy targetColumnCards.

Jeśli karta została znaleziona oraz jej indeks w tej tablicy różni się od zapisanego w stanie, to zwracamy nowy obiekt karty. Do tego obiektu rozpakowujemy wszystkie właściwości karty oraz ustawiamy (i tym samym nadpisujemy) wartość właściwości index.

Zwróć uwagę, że w tym miejscu nie możemy napisać card.index = ..., ponieważ byłoby to złamanie zasady mówiącej, że reducer nie modyfikuje stanu! Dotyczy ona nie tylko otrzymywanego argumentu (statePart), ale również wszystkich obiektów i tablic, które w nim się znajdują. Właśnie dlatego stworzyliśmy nowy obiekt, do którego rozpakowaliśmy właściwości z card.

Przechodząc do bloku else – czyli przypadku, w którym karty nie ma w targetColumnCards, albo jej indeks się nie zmienił, nie potrzebujemy jej zmieniać – zwracamy obiekt karty bez żadnych zmian.

Po tych zmianach powinna już działać poprawnie funkcjonalność przenoszenia kart w obrębie jednej kolumny. Możesz sprawdzić to w przeglądarce.

Przenoszenie kart pomiędzy kolumnami

Jak już wspomnieliśmy, przy przenoszeniu kart pomiędzy kolumnami będziemy mieli trochę bardziej skomplikowaną sytuację. Będziemy potrzebowali posługiwać się dwiema pomocniczymi tablicami – dla kolumny źródłowej (sourceColumnCards) oraz docelowej (targetColumnCards). W obu tych kolumnach będziemy musieli zmienić indeksy tych kart, którym zmieniły się indeksy. Poza tym, dla przenoszonej karty będziemy też chcieli zmieniać jej właściwość columnId.

W tym celu zamień wyrażenie return statePart; w bloku else na poniższy kod:

let sourceColumnCards = statePart.filter(card => card.columnId == src.columnId).sort((a, b) => a.index - b.index);

// remove card from sourceColumn
sourceColumnCards.splice(src.index, 1);
// add card to targetColumn
targetColumnCards.splice(dest.index, 0, targetCard);

console.log('sourceColumnCards:');
console.log(sourceColumnCards.map(card => `${card.index}, title: ${card.title}`));
console.log('targetColumnCards:');
console.log(targetColumnCards.map(card => `${card.index}, title: ${card.title}`));

return statePart.map(card => {
  const targetColumnIndex = targetColumnCards.indexOf(card);

  if(card == targetCard){
    // card is targetCard
    return {...card, index: targetColumnIndex, columnId: dest.columnId};
  } else if(targetColumnIndex > -1 && card.index != targetColumnIndex){
    // card is in targetColumn
    return {...card, index: targetColumnIndex};
  } else {
    // card is NOT in targetColumn
    const sourceColumnIndex = sourceColumnCards.indexOf(card);

    if(sourceColumnIndex > -1 && card.index != sourceColumnIndex){
      // card is in sourceColumn
      return {...card, index: sourceColumnIndex};
    } else {
      // card is NOT in sourceColumn (and NOT in targetColumn)
      return card;
    }
  }
});

Jak widzisz, ten kod jest podobny do znajdującego się we wcześniejszym bloku if, ale jest bardziej rozbudowany. Dodaliśmy tablicę sourceColumnCards i rozbudowaliśmy logikę w zwracanym mapowaniu statePart. Zawarliśmy też parę wyrażeń console.log, aby wyświetlić w konsoli sposób działania tego bloku.

Szczegółową analizę tego fragmentu kodu pozostawiamy już Tobie. Mamy nadzieję, że w oparciu o wyjaśnienia wcześniejszego kodu, poradzisz sobie z jego zrozumieniem.

Podsumowanie implementacji przenoszenia kart

Jak widzisz, nie było to najprostsze zadanie. O ile sama warstwa wizualna wymagała tylko podążania za instrukcją wykorzystania plugin react-beautiful-dnd, to warstwa logiczna wymagała od nas stworzenia algorytmu, który będzie przeliczał indeksy i zapisywał je. Co więcej, musiał on robić to nie tylko dla przenoszonej karty, ale również dla innych kart, którym zmieniły się pozycje w kolumnach.

Nie przejmuj się, jeśli wydaje Ci się to skomplikowane – jest to zadanie, które byłoby zbyt trudne na tym etapie nauki. Dlatego przeszliśmy wspólnie przez proces wdrożenia tej funkcjonalności. Mamy nadzieję, że pomogło Ci to pogłębić nie tylko rozumienie Reduksa i Reacta, ale też myślenie algorytmiczne.

Poświęć teraz chociaż 15 minut na przemyślenie całej tej implementacji – podział na dwie warstwy i miejsce ich styku; przepływ informacji pomiędzy akcją użytkownika na stronie a logiką reducera; wykorzystanie tablic do wygenerowania nowych indeksów; zmianę danych zapisanych w stanie. Pozwól sobie spokojnie pozostać w tym kodzie przez tych kilka chwil.

Nie zapomnij też pobawić się przenoszeniem kart – spróbuj różnych skrajnych przypadków, których mogliśmy nie przemyśleć. Na przykład – czy zadziała przenoszenie karty do kolumny znajdującej się w innej liście? ;)

;